799 lines
18 KiB
Go
799 lines
18 KiB
Go
// backend\auth_passkey.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
)
|
|
|
|
const passkeyChallengeCookieName = "pk_challenge"
|
|
|
|
type recorderWebAuthnUser struct {
|
|
id []byte
|
|
name string
|
|
displayName string
|
|
credentials []webauthn.Credential
|
|
}
|
|
|
|
func (u recorderWebAuthnUser) WebAuthnID() []byte {
|
|
return u.id
|
|
}
|
|
|
|
func (u recorderWebAuthnUser) WebAuthnName() string {
|
|
return u.name
|
|
}
|
|
|
|
func (u recorderWebAuthnUser) WebAuthnDisplayName() string {
|
|
return u.displayName
|
|
}
|
|
|
|
func (u recorderWebAuthnUser) WebAuthnIcon() string {
|
|
return ""
|
|
}
|
|
|
|
func (u recorderWebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
|
return u.credentials
|
|
}
|
|
|
|
func randomBase64URL(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func (am *AuthManager) ensurePasskeyUserID() error {
|
|
am.confMu.Lock()
|
|
needsID := strings.TrimSpace(am.conf.PasskeyUserID) == ""
|
|
if needsID {
|
|
id, err := randomBase64URL(32)
|
|
if err != nil {
|
|
am.confMu.Unlock()
|
|
return err
|
|
}
|
|
am.conf.PasskeyUserID = id
|
|
}
|
|
am.confMu.Unlock()
|
|
|
|
if needsID {
|
|
am.persistConfBestEffort()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (am *AuthManager) initWebAuthn() error {
|
|
origins := authAllowedRPOrigins()
|
|
if len(origins) == 0 {
|
|
return errors.New("no WebAuthn RP origins configured")
|
|
}
|
|
|
|
rpID, err := rpIDForOrigin(origins[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wa, err := webauthn.New(&webauthn.Config{
|
|
RPDisplayName: "Recorder",
|
|
RPID: rpID,
|
|
RPOrigins: origins,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
am.webAuthn = wa
|
|
return nil
|
|
}
|
|
|
|
func authAllowedRPOrigins() []string {
|
|
out := make([]string, 0, 16)
|
|
seen := map[string]bool{}
|
|
|
|
addOrigin := func(origin string) {
|
|
origin, ok := canonicalOrigin(origin)
|
|
if !ok || seen[origin] {
|
|
return
|
|
}
|
|
seen[origin] = true
|
|
out = append(out, origin)
|
|
}
|
|
|
|
for _, origin := range configuredPublicAppOrigins() {
|
|
addOrigin(origin)
|
|
}
|
|
|
|
scheme := "https"
|
|
if !httpsEnabled() {
|
|
scheme = "http"
|
|
}
|
|
|
|
for _, host := range localAppHostCandidates() {
|
|
addOrigin(originForHost(scheme, host, appPort()))
|
|
}
|
|
|
|
// Explicit env values remain supported, but they extend the dynamic list
|
|
// instead of replacing hostname/IP based origins.
|
|
for _, origin := range configuredAuthOrigins() {
|
|
addOrigin(origin)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func configuredPublicAppOrigins() []string {
|
|
keys := []string{
|
|
"APP_PUBLIC_URLS",
|
|
"APP_PUBLIC_URL",
|
|
"PUBLIC_APP_URLS",
|
|
"PUBLIC_APP_URL",
|
|
"NSFWAPP_URL",
|
|
}
|
|
|
|
out := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func configuredAuthOrigins() []string {
|
|
raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS"))
|
|
|
|
// Backward compatible: alter Einzelwert funktioniert weiter.
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("AUTH_RP_ORIGIN"))
|
|
}
|
|
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appPort() string {
|
|
port := strings.TrimSpace(os.Getenv("APP_PORT"))
|
|
if port == "" {
|
|
port = "9999"
|
|
}
|
|
return port
|
|
}
|
|
|
|
func originForHost(scheme, host, port string) string {
|
|
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
|
host = normalizeOriginHost(host)
|
|
port = strings.TrimSpace(port)
|
|
|
|
if scheme == "" || host == "" {
|
|
return ""
|
|
}
|
|
if port == "" {
|
|
return scheme + "://" + host
|
|
}
|
|
|
|
return scheme + "://" + net.JoinHostPort(host, port)
|
|
}
|
|
|
|
func normalizeOriginHost(host string) string {
|
|
host = strings.TrimSpace(host)
|
|
if strings.Contains(host, "://") {
|
|
if u, err := url.Parse(host); err == nil {
|
|
host = u.Hostname()
|
|
}
|
|
} else if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
host = strings.TrimSuffix(host, ".")
|
|
return strings.ToLower(host)
|
|
}
|
|
|
|
func localAppHostCandidates() []string {
|
|
out := make([]string, 0, 8)
|
|
seen := map[string]bool{}
|
|
|
|
add := func(host string) {
|
|
host = normalizeOriginHost(host)
|
|
if host == "" || seen[host] {
|
|
return
|
|
}
|
|
seen[host] = true
|
|
out = append(out, host)
|
|
}
|
|
|
|
add("localhost")
|
|
add("127.0.0.1")
|
|
|
|
if host := strings.TrimSpace(os.Getenv("APP_HOST")); host != "" {
|
|
add(host)
|
|
}
|
|
|
|
if host, err := os.Hostname(); err == nil {
|
|
add(host)
|
|
host = normalizeOriginHost(host)
|
|
if host != "" && !strings.Contains(host, ".") {
|
|
add(host + ".local")
|
|
}
|
|
}
|
|
|
|
addInterfaceIPv4Hosts(add)
|
|
|
|
return out
|
|
}
|
|
|
|
func addInterfaceIPv4Hosts(add func(string)) {
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
default:
|
|
continue
|
|
}
|
|
|
|
ip4 := ip.To4()
|
|
if ip4 == nil || ip4.IsLoopback() || ip4.IsUnspecified() || ip4.IsMulticast() {
|
|
continue
|
|
}
|
|
|
|
add(ip4.String())
|
|
}
|
|
}
|
|
|
|
func canonicalOrigin(origin string) (string, bool) {
|
|
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
|
if origin == "" {
|
|
return "", false
|
|
}
|
|
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
|
host := normalizeOriginHost(u.Hostname())
|
|
if scheme == "" || host == "" {
|
|
return "", false
|
|
}
|
|
if scheme != "http" && scheme != "https" {
|
|
return "", false
|
|
}
|
|
|
|
port := strings.TrimSpace(u.Port())
|
|
if port == "" {
|
|
return scheme + "://" + host, true
|
|
}
|
|
|
|
return scheme + "://" + net.JoinHostPort(host, port), true
|
|
}
|
|
|
|
func originFromRequest(r *http.Request) string {
|
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
|
if origin != "" {
|
|
if normalized, ok := canonicalOrigin(origin); ok {
|
|
return normalized
|
|
}
|
|
return strings.TrimRight(origin, "/")
|
|
}
|
|
|
|
proto := "http"
|
|
if isSecureRequest(r) {
|
|
proto = "https"
|
|
}
|
|
|
|
host := strings.TrimSpace(r.Host)
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
|
|
return proto + "://" + host
|
|
}
|
|
|
|
func rpIDFromOrigin(origin string) (string, error) {
|
|
u, err := url.Parse(strings.TrimSpace(origin))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
host := normalizeOriginHost(u.Hostname())
|
|
if host == "" {
|
|
return "", errors.New("origin has no hostname")
|
|
}
|
|
|
|
return host, nil
|
|
}
|
|
|
|
func rpIDIsValidForOrigin(rpID string, origin string) bool {
|
|
rpID = strings.ToLower(strings.TrimSpace(rpID))
|
|
if rpID == "" {
|
|
return false
|
|
}
|
|
|
|
u, err := url.Parse(strings.TrimSpace(origin))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
|
if host == "" {
|
|
return false
|
|
}
|
|
|
|
// IP-Adressen dürfen bei WebAuthn nur exakt als RP-ID verwendet werden.
|
|
if net.ParseIP(host) != nil {
|
|
return rpID == host
|
|
}
|
|
|
|
// localhost darf ebenfalls nur exakt localhost sein.
|
|
if host == "localhost" {
|
|
return rpID == "localhost"
|
|
}
|
|
|
|
// Normale Domains: RP-ID darf Host selbst oder Parent-Domain sein.
|
|
return host == rpID || strings.HasSuffix(host, "."+rpID)
|
|
}
|
|
|
|
func rpIDForOrigin(origin string) (string, error) {
|
|
configured := strings.TrimSpace(os.Getenv("AUTH_RP_ID"))
|
|
|
|
if configured != "" {
|
|
if rpIDIsValidForOrigin(configured, origin) {
|
|
return configured, nil
|
|
}
|
|
|
|
appLogln(
|
|
"⚠️ AUTH_RP_ID passt nicht zur aktuellen Origin, nutze Origin-Host:",
|
|
"AUTH_RP_ID=", configured,
|
|
"origin=", origin,
|
|
)
|
|
}
|
|
|
|
return rpIDFromOrigin(origin)
|
|
}
|
|
|
|
func originIsAllowed(origin string, allowed []string) bool {
|
|
normalizedOrigin, ok := canonicalOrigin(origin)
|
|
if ok {
|
|
origin = normalizedOrigin
|
|
} else {
|
|
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
|
|
}
|
|
|
|
for _, a := range allowed {
|
|
normalizedAllowed, ok := canonicalOrigin(a)
|
|
if ok {
|
|
a = normalizedAllowed
|
|
} else {
|
|
a = strings.TrimRight(strings.TrimSpace(a), "/")
|
|
}
|
|
|
|
if origin == a {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func requestHostOriginAllowed(origin string, r *http.Request) bool {
|
|
normalizedOrigin, ok := canonicalOrigin(origin)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
u, err := url.Parse(normalizedOrigin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
originHost := normalizeOriginHost(u.Hostname())
|
|
requestHost, requestPort := splitRequestHostPort(r.Host)
|
|
if originHost == "" || requestHost == "" || originHost != requestHost {
|
|
return false
|
|
}
|
|
|
|
if strings.TrimSpace(u.Port()) != requestPort {
|
|
return false
|
|
}
|
|
|
|
if u.Scheme == "https" {
|
|
return true
|
|
}
|
|
|
|
return u.Scheme == "http" && isLocalWebAuthnHost(originHost)
|
|
}
|
|
|
|
func splitRequestHostPort(host string) (string, string) {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return "", ""
|
|
}
|
|
|
|
if h, p, err := net.SplitHostPort(host); err == nil {
|
|
return normalizeOriginHost(h), strings.TrimSpace(p)
|
|
}
|
|
|
|
return normalizeOriginHost(host), ""
|
|
}
|
|
|
|
func isLocalWebAuthnHost(host string) bool {
|
|
host = normalizeOriginHost(host)
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) {
|
|
origin := originFromRequest(r)
|
|
if origin == "" {
|
|
return nil, errors.New("request origin missing")
|
|
}
|
|
|
|
allowedOrigins := authAllowedRPOrigins()
|
|
if !originIsAllowed(origin, allowedOrigins) && !requestHostOriginAllowed(origin, r) {
|
|
return nil, errors.New("origin not allowed for WebAuthn: " + origin)
|
|
}
|
|
|
|
rpID, err := rpIDForOrigin(origin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
appLogln("🔐 WebAuthn Request:", "origin=", origin, "rpID=", rpID)
|
|
|
|
return webauthn.New(&webauthn.Config{
|
|
RPDisplayName: "Recorder",
|
|
RPID: rpID,
|
|
RPOrigins: []string{origin},
|
|
})
|
|
}
|
|
|
|
func (am *AuthManager) webAuthnUser() (recorderWebAuthnUser, error) {
|
|
am.confMu.Lock()
|
|
defer am.confMu.Unlock()
|
|
|
|
idRaw := strings.TrimSpace(am.conf.PasskeyUserID)
|
|
id, err := base64.RawURLEncoding.DecodeString(idRaw)
|
|
if err != nil {
|
|
return recorderWebAuthnUser{}, err
|
|
}
|
|
|
|
username := strings.TrimSpace(am.conf.Username)
|
|
if username == "" {
|
|
username = "admin"
|
|
}
|
|
|
|
return recorderWebAuthnUser{
|
|
id: id,
|
|
name: username,
|
|
displayName: username,
|
|
credentials: append([]webauthn.Credential(nil), am.conf.Passkeys...),
|
|
}, nil
|
|
}
|
|
|
|
func setPasskeyChallengeCookie(w http.ResponseWriter, id string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: passkeyChallengeCookieName,
|
|
Value: id,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: secure,
|
|
Expires: time.Now().Add(5 * time.Minute),
|
|
})
|
|
}
|
|
|
|
func clearPasskeyChallengeCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: passkeyChallengeCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (am *AuthManager) savePasskeySession(key string, data *webauthn.SessionData) {
|
|
am.passkeyMu.Lock()
|
|
defer am.passkeyMu.Unlock()
|
|
am.passkeySessions[key] = data
|
|
}
|
|
|
|
func (am *AuthManager) takePasskeySession(key string) *webauthn.SessionData {
|
|
am.passkeyMu.Lock()
|
|
defer am.passkeyMu.Unlock()
|
|
|
|
data := am.passkeySessions[key]
|
|
delete(am.passkeySessions, key)
|
|
return data
|
|
}
|
|
|
|
func authPasskeyRegisterOptionsHandler(am *AuthManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
s, sid := am.getSession(r)
|
|
if s == nil || !s.Authed || sid == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
user, err := am.webAuthnUser()
|
|
if err != nil {
|
|
http.Error(w, "passkey user error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
wa, err := am.webAuthnForRequest(r)
|
|
if err != nil {
|
|
appLogln("passkey registration options config failed:", err)
|
|
http.Error(w, "passkey registration options config failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
options, sessionData, err := wa.BeginRegistration(
|
|
user,
|
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
|
webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
|
|
webauthn.WithExtensions(map[string]any{"credProps": true}),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "passkey registration options failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
am.savePasskeySession("register:"+sid, sessionData)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(options)
|
|
}
|
|
}
|
|
|
|
func authPasskeyRegisterVerifyHandler(am *AuthManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
s, sid := am.getSession(r)
|
|
if s == nil || !s.Authed || sid == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sessionData := am.takePasskeySession("register:" + sid)
|
|
if sessionData == nil {
|
|
http.Error(w, "passkey challenge expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, err := am.webAuthnUser()
|
|
if err != nil {
|
|
http.Error(w, "passkey user error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
wa, err := am.webAuthnForRequest(r)
|
|
if err != nil {
|
|
appLogln("passkey registration verify config failed:", err)
|
|
http.Error(w, "passkey registration verify config failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
credential, err := wa.FinishRegistration(user, *sessionData, r)
|
|
if err != nil {
|
|
appLogln("passkey registration verify failed:", err)
|
|
http.Error(w, "passkey registration verify failed: "+err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
now := time.Now().Format(time.RFC3339)
|
|
credentialID := credentialIDString(credential.ID)
|
|
|
|
am.confMu.Lock()
|
|
am.conf.Passkeys = append(am.conf.Passkeys, *credential)
|
|
am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{
|
|
CredentialID: credentialID,
|
|
Label: "Passkey " + shortCredentialID(credentialID),
|
|
CreatedAt: now,
|
|
})
|
|
am.confMu.Unlock()
|
|
|
|
go am.persistConfBestEffort()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
}
|
|
}
|
|
|
|
func authPasskeyLoginOptionsHandler(am *AuthManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
user, err := am.webAuthnUser()
|
|
if err != nil {
|
|
http.Error(w, "passkey user error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if len(user.credentials) == 0 {
|
|
http.Error(w, "no passkey configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
wa, err := am.webAuthnForRequest(r)
|
|
if err != nil {
|
|
appLogln("passkey login options config failed:", err)
|
|
http.Error(w, "passkey login options config failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
options, sessionData, err := wa.BeginLogin(
|
|
user,
|
|
webauthn.WithUserVerification(protocol.VerificationPreferred),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "passkey login options failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
challengeID, err := randomBase64URL(24)
|
|
if err != nil {
|
|
http.Error(w, "challenge error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
am.savePasskeySession("login:"+challengeID, sessionData)
|
|
setPasskeyChallengeCookie(w, challengeID, isSecureRequest(r))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(options)
|
|
}
|
|
}
|
|
|
|
func authPasskeyLoginVerifyHandler(am *AuthManager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
c, err := r.Cookie(passkeyChallengeCookieName)
|
|
if err != nil || strings.TrimSpace(c.Value) == "" {
|
|
http.Error(w, "passkey challenge missing", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
challengeID := strings.TrimSpace(c.Value)
|
|
clearPasskeyChallengeCookie(w)
|
|
|
|
sessionData := am.takePasskeySession("login:" + challengeID)
|
|
if sessionData == nil {
|
|
http.Error(w, "passkey challenge expired", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
user, err := am.webAuthnUser()
|
|
if err != nil {
|
|
http.Error(w, "passkey user error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
wa, err := am.webAuthnForRequest(r)
|
|
if err != nil {
|
|
appLogln("passkey login verify config failed:", err)
|
|
http.Error(w, "passkey login verify config failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
credential, err := wa.FinishLogin(user, *sessionData, r)
|
|
if err != nil {
|
|
appLogln("passkey login verify failed:", err)
|
|
http.Error(w, "passkey login verify failed: "+err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
credentialID := credentialIDString(credential.ID)
|
|
nowISO := time.Now().Format(time.RFC3339)
|
|
|
|
am.confMu.Lock()
|
|
for i := range am.conf.Passkeys {
|
|
if string(am.conf.Passkeys[i].ID) == string(credential.ID) {
|
|
am.conf.Passkeys[i].Authenticator.SignCount = credential.Authenticator.SignCount
|
|
break
|
|
}
|
|
}
|
|
am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{
|
|
CredentialID: credentialID,
|
|
LastUsedAt: nowISO,
|
|
})
|
|
username := am.conf.Username
|
|
am.confMu.Unlock()
|
|
|
|
go am.persistConfBestEffort()
|
|
|
|
sid, err := newSessionID()
|
|
if err != nil {
|
|
http.Error(w, "session error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
s := &session{
|
|
User: username,
|
|
Authed: true,
|
|
Pending2FA: false,
|
|
CreatedAt: now,
|
|
LastSeenAt: now,
|
|
ExpiresAt: now.Add(sessionTTL),
|
|
}
|
|
|
|
am.sessMu.Lock()
|
|
am.sess[sid] = s
|
|
am.sessMu.Unlock()
|
|
|
|
setSessionCookie(w, sid, isSecureRequest(r))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
}
|
|
}
|