added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s

This commit is contained in:
2026-06-10 03:24:31 +02:00
parent 01ec41288a
commit fb3be56959
18 changed files with 1680 additions and 61 deletions

View File

@@ -672,6 +672,10 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
twoFactorStatus = "setup_pending"
}
recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold
passkeyCount := 0
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
passkeyCount = count
}
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -683,6 +687,8 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
"recovery_codes_remaining": recoveryCodesRemaining,
"recovery_codes_warning": recoveryCodesWarning,
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
"passkeys_enabled": passkeyCount > 0,
"passkey_count": passkeyCount,
})
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
@@ -732,6 +738,11 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
return
}
passkeyCount := 0
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
passkeyCount = count
}
setAuthCookies(w, accessToken, refreshTokenPlain)
response := map[string]interface{}{
"access_token": accessToken,
@@ -741,6 +752,8 @@ func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user mod
"username": user.Username,
"role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled,
"passkeys_enabled": passkeyCount > 0,
"passkey_count": passkeyCount,
},
}
for key, value := range extra {

580
handlers/passkeys.go Normal file
View File

@@ -0,0 +1,580 @@
package handlers
import (
"MiauInv/auth"
"MiauInv/models"
"MiauInv/storage"
utils "MiauInv/util"
"bytes"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyChallengeTTL = 5 * time.Minute
type passkeyWebAuthnUser struct {
user models.User
credentials []webauthn.Credential
}
func (user passkeyWebAuthnUser) WebAuthnID() []byte {
return []byte(user.user.ID)
}
func (user passkeyWebAuthnUser) WebAuthnName() string {
return user.user.Username
}
func (user passkeyWebAuthnUser) WebAuthnDisplayName() string {
return user.user.Username
}
func (user passkeyWebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
return user.credentials
}
func Passkeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listPasskeys(w, r)
case http.MethodDelete:
deletePasskey(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func PasskeyRegisterOptions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Password) == "" {
http.Error(w, "Current password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
waUser, err := loadPasskeyWebAuthnUser(user)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
creation, sessionData, err := wa.BeginRegistration(waUser,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
}),
webauthn.WithExclusions(webauthn.Credentials(waUser.WebAuthnCredentials()).CredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
)
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey registration challenge", http.StatusInternalServerError)
return
}
sessionToken, err := utils.GenerateOpaqueToken()
if err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
return
}
if err := storage.SavePasskeyChallenge(sessionToken, user.ID, storage.PasskeyCeremonyRegister, *sessionData, passkeyChallengeTTL); err != nil {
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
return
}
_ = storage.CleanupExpiredPasskeyChallenges()
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_token": sessionToken,
"options": creation,
})
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": Created passkey registration challenge")
}
func PasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
SessionToken string `json:"session_token"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.SessionToken == "" || len(req.Credential) == 0 {
http.Error(w, "Session token and credential required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyRegister)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired passkey registration challenge", http.StatusUnauthorized)
return
}
if challenge.UserID != user.ID {
http.Error(w, "Invalid passkey registration challenge", http.StatusUnauthorized)
return
}
waUser, err := loadPasskeyWebAuthnUser(user)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
credential, err := wa.FinishRegistration(waUser, sessionData, credentialRequest)
if err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not verify passkey registration", http.StatusUnauthorized)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Passkey"
}
if _, err := storage.AddPasskeyCredential(user.ID, name, credential); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": true,
"passkey_count": len(passkeys),
"passkeys": publicPasskeys(passkeys),
})
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": Registered passkey and revoked old sessions")
}
func PasskeyLoginOptions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey login challenge", http.StatusInternalServerError)
return
}
sessionToken, err := utils.GenerateOpaqueToken()
if err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
return
}
if err := storage.SavePasskeyChallenge(sessionToken, "", storage.PasskeyCeremonyLogin, *sessionData, passkeyChallengeTTL); err != nil {
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
return
}
_ = storage.CleanupExpiredPasskeyChallenges()
writeJSON(w, http.StatusOK, map[string]interface{}{
"session_token": sessionToken,
"options": assertion,
})
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": Created passkey login challenge")
}
func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
SessionToken string `json:"session_token"`
Credential json.RawMessage `json:"credential"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.SessionToken == "" || len(req.Credential) == 0 {
http.Error(w, "Session token and credential required", http.StatusBadRequest)
return
}
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyLogin)
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired passkey login challenge", http.StatusUnauthorized)
return
}
wa, err := webAuthnForRequest(r)
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
return
}
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
var user models.User
var credential *webauthn.Credential
if challenge.UserID == "" {
var webAuthnUser webauthn.User
webAuthnUser, credential, err = wa.FinishPasskeyLogin(passkeyDiscoverableUserHandler, sessionData, credentialRequest)
if err == nil {
resolvedUser, ok := webAuthnUser.(passkeyWebAuthnUser)
if !ok {
err = errors.New("invalid passkey user type")
} else {
user = resolvedUser.user
}
}
} else {
user, err = storage.GetUserById(challenge.UserID)
if err == nil {
waUser, loadErr := loadPasskeyWebAuthnUser(user)
if loadErr != nil {
err = loadErr
} else {
credential, err = wa.FinishLogin(waUser, sessionData, credentialRequest)
}
}
}
if err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
return
}
if err := storage.UpdatePasskeyCredentialAfterLogin(user.ID, credential); err != nil {
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not update passkey", http.StatusInternalServerError)
return
}
issueLoginSession(w, r, user)
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
}
func PasskeyDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if err := storage.DeleteAllPasskeyCredentials(user.ID); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not disable passkeys", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": false,
"passkey_count": 0,
"passkeys": []map[string]interface{}{},
})
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": Disabled passkeys and revoked old sessions")
}
func listPasskeys(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
passkeys, err := storage.ListPasskeyCredentials(claims.UserID)
if err != nil {
log.Println("GET [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"passkeys": publicPasskeys(passkeys),
"passkeys_enabled": len(passkeys) > 0,
"passkey_count": len(passkeys),
})
}
func deletePasskey(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.ID == "" || req.Password == "" {
http.Error(w, "Passkey ID and password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if _, err := storage.GetPasskeyCredentialByID(user.ID, req.ID); err != nil {
http.Error(w, "Passkey not found", http.StatusNotFound)
return
}
if err := storage.DeletePasskeyCredential(user.ID, req.ID); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not delete passkey", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"passkeys_enabled": len(passkeys) > 0,
"passkey_count": len(passkeys),
"passkeys": publicPasskeys(passkeys),
})
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": Deleted passkey and revoked old sessions")
}
func loadPasskeyWebAuthnUser(user models.User) (passkeyWebAuthnUser, error) {
rows, err := storage.ListPasskeyCredentials(user.ID)
if err != nil {
return passkeyWebAuthnUser{}, err
}
credentials, err := storage.DecodeWebAuthnCredentials(rows)
if err != nil {
return passkeyWebAuthnUser{}, err
}
return passkeyWebAuthnUser{user: user, credentials: credentials}, nil
}
func passkeyDiscoverableUserHandler(rawID, userHandle []byte) (webauthn.User, error) {
row, err := storage.GetPasskeyCredentialByCredentialID(utils.EncodeBase64URL(rawID))
if err != nil {
return nil, err
}
if row.UserID != string(userHandle) {
return nil, errors.New("passkey user handle mismatch")
}
user, err := storage.GetUserById(row.UserID)
if err != nil {
return nil, err
}
return loadPasskeyWebAuthnUser(user)
}
func publicPasskeys(passkeys []models.PasskeyCredential) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(passkeys))
for _, passkey := range passkeys {
item := map[string]interface{}{
"id": passkey.ID,
"name": passkey.Name,
"credential_id": passkey.CredentialID,
"created_at": passkey.CreatedAt,
}
if passkey.LastUsedAt > 0 {
item["last_used_at"] = passkey.LastUsedAt
}
out = append(out, item)
}
return out
}
func webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) {
origin, rpID, err := passkeyOriginAndRPID(r)
if err != nil {
return nil, err
}
return webauthn.New(&webauthn.Config{
RPID: rpID,
RPDisplayName: "MiauInv",
RPOrigins: []string{origin},
RPTopOrigins: []string{origin},
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
AuthenticatorSelection: protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
}
func passkeyOriginAndRPID(r *http.Request) (string, string, error) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if scheme == "" {
scheme = "https"
}
host := requestHost(r)
if host == "" {
return "", "", errors.New("missing request host")
}
origin = scheme + "://" + host
}
parsedOrigin, err := url.Parse(origin)
if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" {
return "", "", errors.New("invalid origin")
}
if parsedOrigin.Scheme != "https" && parsedOrigin.Hostname() != "localhost" {
return "", "", errors.New("passkeys require HTTPS except for localhost")
}
originHost := strings.ToLower(parsedOrigin.Hostname())
allowedHost := strings.ToLower(stripPort(requestHost(r)))
if allowedHost != "" && originHost != allowedHost {
return "", "", errors.New("origin host does not match request host")
}
return parsedOrigin.Scheme + "://" + parsedOrigin.Host, originHost, nil
}
func requestHost(r *http.Request) string {
if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
parts := strings.Split(forwardedHost, ",")
return strings.TrimSpace(parts[0])
}
return strings.TrimSpace(r.Host)
}
func stripPort(host string) string {
if host == "" {
return ""
}
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
if strings.Count(host, ":") == 0 {
return host
}
return host
}
func cloneRequestWithJSONBody(r *http.Request, raw json.RawMessage) *http.Request {
clone := r.Clone(r.Context())
clone.Body = io.NopCloser(bytes.NewReader(raw))
clone.ContentLength = int64(len(raw))
clone.Header = r.Header.Clone()
clone.Header.Set("Content-Type", "application/json")
return clone
}