wireguard-ui/handler/passkeys.go

802 lines
27 KiB
Go

package handler
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/rs/xid"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/ngoduykhanh/wireguard-ui/locale"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/ngoduykhanh/wireguard-ui/util"
)
type webAuthnUser struct {
u model.User
}
var (
passkeyRegMu sync.Mutex
passkeyRegSessions = map[string]webauthn.SessionData{}
passkeyLoginMu sync.Mutex
passkeyLoginSessions = map[string]webauthn.SessionData{}
)
// ClearStoredPasskeysForAllUsers removes WebAuthn credentials from every user (e.g. after disabling Passkeys in settings).
func ClearStoredPasskeysForAllUsers(db store.IStore) error {
if db == nil {
return nil
}
users, err := db.GetUsers()
if err != nil {
return err
}
for _, u := range users {
if len(u.Passkeys) == 0 {
continue
}
u.Passkeys = nil
if err := db.SaveUser(u); err != nil {
return fmt.Errorf("%s: %w", u.Username, err)
}
}
return nil
}
func (w webAuthnUser) WebAuthnID() []byte {
return []byte(w.u.Username)
}
func (w webAuthnUser) WebAuthnName() string {
return w.u.Username
}
func (w webAuthnUser) WebAuthnDisplayName() string {
if s := strings.TrimSpace(w.u.DisplayName); s != "" {
return s
}
return w.u.Username
}
func (w webAuthnUser) WebAuthnCredentials() []webauthn.Credential {
return w.u.Passkeys
}
func (w webAuthnUser) WebAuthnIcon() string { return "" }
// androidApkOriginsFromPasskeySHA256Env builds WebAuthn origins for Credential Manager using the Android app
// signing key (same hex SHA-256 as WGUI_ANDROID_PASSKEY_SHA256 / assetlinks). ClientDataJSON.origin for native
// login is typically "android:apk-key-hash:"+base64url(SHA256(cert)) — see FullyQualifiedOrigin in go-webauthn.
func androidApkOriginsFromPasskeySHA256Env() []string {
csv := util.AndroidPasskeySHA256FingerprintsCSV()
if csv == "" {
return nil
}
var out []string
for _, frag := range strings.Split(csv, ",") {
fp, err := normalizeAndroidCertFingerprint(strings.TrimSpace(frag))
if err != nil {
continue
}
raw, err := hex.DecodeString(strings.ReplaceAll(fp, ":", ""))
if err != nil || len(raw) != sha256digestLen {
continue
}
out = append(out, androidApkOriginPrefix+base64.RawURLEncoding.EncodeToString(raw))
}
return out
}
const (
sha256digestLen = 32
androidApkOriginPrefix = "android:apk-key-hash:"
)
func rpOriginsContainsExact(origins []string, needle string) bool {
for _, o := range origins {
if o == needle {
return true
}
}
return false
}
func webauthnForRequest(c echo.Context) (*webauthn.WebAuthn, error) {
reqHost := c.Request().Header.Get("X-Forwarded-Host")
if reqHost == "" {
reqHost = c.Request().Host
}
if strings.Contains(reqHost, ",") {
reqHost = strings.TrimSpace(strings.Split(reqHost, ",")[0])
}
proto := c.Request().Header.Get("X-Forwarded-Proto")
if proto == "" {
if c.Request().TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
proto = strings.TrimSpace(strings.ToLower(proto))
defaultOrigin := webauthnBuildOrigin(proto, reqHost)
rpHost := webauthnHostOnlyFromRequest(reqHost)
if rpHost == "" {
rpHost = "localhost"
}
rpDisplayName := util.LookupEnvOrString(util.WebAuthnRPDisplayNameEnvVar, "WireGuard UI")
envRPID := util.LookupEnvOrString(util.WebAuthnRPIDEnvVar, "")
rpOrigins := trimOriginList(util.LookupEnvOrStrings(util.WebAuthnRPOriginsEnvVar, []string{defaultOrigin}))
if len(rpOrigins) == 0 {
rpOrigins = []string{defaultOrigin}
}
mobileRpHostForced := false
if hint := strings.TrimSpace(c.Request().Header.Get(util.WebAuthnPublicOriginHeader)); hint != "" {
norm, err := normalizeWebAuthnOrigin(hint)
if err == nil && mobilePasskeyOriginTrusted(norm, rpOrigins, envRPID, defaultOrigin) {
mobileRpHostForced = true
if u, errP := url.Parse(norm); errP == nil && u.Hostname() != "" {
rpHost = u.Hostname()
}
if !originListContainsNormalized(rpOrigins, norm) {
rpOrigins = append([]string{norm}, rpOrigins...)
}
}
}
// Credential Manager verifies https://<rpId>/.well-known/assetlinks.json. That host must match
// rp.id in WebAuthn options. If clients send X-WGUI-WebAuthn-Public-Origin (public HTTPS host)
// but WGUI_WEBAUTHN_RP_ID was set for an internal LAN hostname, rp.id would mismatch and Android
// raises "RP ID cannot be validated" before the assertion reaches our server.
rpID := strings.TrimSpace(envRPID)
if rpID == "" {
rpID = rpHost
} else if mobileRpHostForced && rpHost != "" &&
!strings.EqualFold(hostOnlyRPID(rpID), rpHost) {
log.Warnf("[passkeys] WGUI_WEBAUTHN_RP_ID %q disagrees with %s rp host %q; using hinted host so Android Credential Manager can validate RP ID",
rpID, util.WebAuthnPublicOriginHeader, rpHost)
rpID = rpHost
}
androidConfigured := strings.TrimSpace(util.AndroidPasskeySHA256FingerprintsCSV()) != ""
for _, ao := range androidApkOriginsFromPasskeySHA256Env() {
if !rpOriginsContainsExact(rpOrigins, ao) {
rpOrigins = append(rpOrigins, ao)
}
}
return webauthn.New(&webauthn.Config{
RPDisplayName: rpDisplayName,
RPID: rpID,
RPOrigins: rpOrigins,
// Hybrid / native Credential Manager assertions may set CollectedClientData.crossOrigin=true.
RPAllowCrossOrigin: androidConfigured,
})
}
func webauthnBuildOrigin(proto, hostPort string) string {
u := &url.URL{Scheme: proto, Host: strings.TrimSpace(hostPort)}
return strings.TrimSuffix(u.String(), "/")
}
func webauthnHostOnlyFromRequest(hostPort string) string {
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
return ""
}
h, _, err := net.SplitHostPort(hostPort)
if err != nil {
if i := strings.LastIndex(hostPort, ":"); i > 0 && !strings.HasPrefix(hostPort, "[") {
return strings.ToLower(hostPort[:i])
}
return strings.ToLower(hostPort)
}
return strings.ToLower(h)
}
func trimOriginList(origins []string) []string {
out := make([]string, 0, len(origins))
for _, o := range origins {
s := strings.TrimSpace(o)
if s != "" {
out = append(out, s)
}
}
return out
}
func normalizeWebAuthnOrigin(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", fmt.Errorf("empty origin")
}
u, err := url.Parse(s)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("invalid origin")
}
scheme := strings.ToLower(u.Scheme)
host := strings.ToLower(u.Hostname())
if host == "" {
return "", fmt.Errorf("invalid origin host")
}
port := u.Port()
if scheme != "https" && !(scheme == "http" && (host == "localhost" || strings.HasPrefix(host, "127."))) {
return "", fmt.Errorf("origin must use https (http allowed only for localhost)")
}
if port != "" && !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) {
return fmt.Sprintf("%s://%s:%s", scheme, host, port), nil
}
return scheme + "://" + host, nil
}
func originListContainsNormalized(origins []string, norm string) bool {
for _, o := range origins {
no, err := normalizeWebAuthnOrigin(strings.TrimSpace(o))
if err == nil && strings.EqualFold(no, norm) {
return true
}
}
return false
}
func hostOnlyRPID(rp string) string {
rp = strings.TrimSpace(strings.ToLower(rp))
if rp == "" {
return ""
}
if h, _, err := net.SplitHostPort(rp); err == nil {
return h
}
return rp
}
func mobilePasskeyOriginTrusted(norm string, rpOrigins []string, envRPID string, defaultOrigin string) bool {
if originListContainsNormalized(rpOrigins, norm) {
return true
}
if envRPID != "" {
if u, err := url.Parse(norm); err == nil {
if strings.EqualFold(u.Hostname(), hostOnlyRPID(envRPID)) {
return true
}
}
}
if do, err := normalizeWebAuthnOrigin(defaultOrigin); err == nil && strings.EqualFold(do, norm) {
return true
}
return strings.EqualFold(strings.TrimRight(defaultOrigin, "/"), strings.TrimRight(norm, "/"))
}
func setLoginSession(c echo.Context, dbuser model.User, rememberMe bool, sessionTimeoutMinutes int) error {
ageMax := 0
if sessionTimeoutMinutes > 0 {
ageMax = sessionTimeoutMinutes * 60
} else if rememberMe {
// Only extend cookie max-age when global settings do not enforce a finite session idle timeout.
rememberAge := 86400 * 7
if ageMax < rememberAge {
ageMax = rememberAge
}
}
if ageMax <= 0 {
ageMax = 86400
}
if isWireguardUiAndroidClient(c) && ageMax < androidCompanionIdleMinSeconds {
ageMax = androidCompanionIdleMinSeconds
}
cookiePath := util.GetCookiePath()
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: cookiePath,
MaxAge: ageMax,
}
util.ApplySessionSecureFlags(sess.Options)
tokenUID := xid.New().String()
now := time.Now().UTC().Unix()
userCRC := util.GetDBUserCRC32(dbuser)
util.DBUsersToCRC32[dbuser.Username] = userCRC
delete(sess.Values, sessionPkLoginCredKey)
sess.Values["username"] = dbuser.Username
sess.Values["user_hash"] = userCRC
sess.Values["admin"] = dbuser.Admin
sess.Values["session_token"] = tokenUID
sess.Values["max_age"] = ageMax
sess.Values["created_at"] = now
sess.Values["updated_at"] = now
if err := sess.Save(c.Request(), c.Response()); err != nil {
return err
}
cookie := new(http.Cookie)
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.Value = tokenUID
cookie.MaxAge = ageMax
util.ApplySessionHTTPFlags(cookie)
c.SetCookie(cookie)
return nil
}
func persistSessionPasskeyLoginCredential(c echo.Context, cred *webauthn.Credential) {
if cred == nil || len(cred.ID) == 0 {
return
}
sess, _ := session.Get("session", c)
sess.Values[sessionPkLoginCredKey] = base64.RawURLEncoding.EncodeToString(cred.ID)
_ = sess.Save(c.Request(), c.Response())
}
func PasskeyBeginRegister(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled in settings"})
}
username := c.Param("username")
if username == "" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Missing username"})
}
if !isAdmin(c) && username != currentUser(c) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Not allowed"})
}
u, err := db.GetUserByName(username)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
wa, err := webauthnForRequest(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
// Prefer resident (discoverable) credentials so login without username works on supported authenticators.
opts, sessionData, err := wa.BeginRegistration(webAuthnUser{u}, webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred))
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
sess, _ := session.Get("session", c)
token := xid.New().String()
passkeyRegMu.Lock()
passkeyRegSessions[token] = *sessionData
passkeyRegMu.Unlock()
sess.Values["passkey_reg_"+username] = token
_ = sess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, opts)
}
}
func PasskeyFinishRegister(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled in settings"})
}
username := c.Param("username")
if username == "" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Missing username"})
}
if !isAdmin(c) && username != currentUser(c) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Not allowed"})
}
u, err := db.GetUserByName(username)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
bodyBytes, errRead := io.ReadAll(c.Request().Body)
if errRead != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Cannot read request body"})
}
var raw map[string]interface{}
credentialName := ""
if json.Unmarshal(bodyBytes, &raw) == nil && raw != nil {
if v, ok := raw["credential_name"].(string); ok {
credentialName = strings.TrimSpace(v)
}
delete(raw, "credential_name")
if clean, errM := json.Marshal(raw); errM == nil {
bodyBytes = clean
}
}
c.Request().Body = io.NopCloser(bytes.NewReader(bodyBytes))
wa, err := webauthnForRequest(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
sess, _ := session.Get("session", c)
token, _ := sess.Values["passkey_reg_"+username].(string)
if token == "" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Registration session not found"})
}
passkeyRegMu.Lock()
sessionData, ok := passkeyRegSessions[token]
passkeyRegMu.Unlock()
if !ok {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Registration session expired"})
}
cred, err := wa.FinishRegistration(webAuthnUser{u}, sessionData, c.Request())
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
already := false
for _, ex := range u.Passkeys {
if len(ex.ID) > 0 && bytes.Equal(ex.ID, cred.ID) {
already = true
break
}
}
if !already {
u.Passkeys = append(u.Passkeys, *cred)
idKey := base64.RawURLEncoding.EncodeToString(cred.ID)
if credentialName != "" {
if u.PasskeyLabels == nil {
u.PasskeyLabels = map[string]string{}
}
u.PasskeyLabels[idKey] = credentialName
}
if err := db.SaveUser(u); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
if username == currentUser(c) {
// Keep session hash in the same session instance; a second sess.Save() happens below.
sess.Values["username"] = u.Username
sess.Values["admin"] = u.Admin
sess.Values["user_hash"] = util.GetDBUserCRC32(u)
}
}
delete(sess.Values, "passkey_reg_"+username)
passkeyRegMu.Lock()
delete(passkeyRegSessions, token)
passkeyRegMu.Unlock()
_ = sess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Passkey registered"})
}
}
// PasskeyRemove deletes one stored credential by id (base64url).
func PasskeyRemove(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled in settings"})
}
lang := locale.Normalize(globalSettings.UILanguage)
var body struct {
Username string `json:"username"`
CredentialID string `json:"credential_id"`
}
if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
un := strings.TrimSpace(body.Username)
if un == "" || !usernameRegexp.MatchString(un) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid username"})
}
if !isAdmin(c) && un != currentUser(c) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Not allowed"})
}
wantID, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(body.CredentialID))
if err != nil || len(wantID) == 0 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid credential id"})
}
idKey := base64.RawURLEncoding.EncodeToString(wantID)
u, err := db.GetUserByName(un)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
kept := u.Passkeys[:0]
for _, pc := range u.Passkeys {
if len(pc.ID) > 0 && bytes.Equal(pc.ID, wantID) {
continue
}
kept = append(kept, pc)
}
if len(kept) == len(u.Passkeys) {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Credential not found"})
}
u.Passkeys = kept
if u.PasskeyLabels != nil {
delete(u.PasskeyLabels, idKey)
}
// Any passkey removal revokes existing sessions for that account.
u.AuthEpoch++
if err := db.SaveUser(u); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
if un == currentUser(c) {
clearSession(c)
return c.JSON(http.StatusOK, jsonHTTPReauthenticate{
Status: true,
Message: locale.T(lang, "api.passkey_removed_reauth"),
Reauthenticate: true,
})
}
return c.JSON(http.StatusOK, jsonHTTPResponse{Status: true, Message: locale.T(lang, "api.passkey_removed_sessions_revoked")})
}
}
// PasskeyRename updates the friendly name for an existing credential.
func PasskeyRename(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled in settings"})
}
var body struct {
Username string `json:"username"`
CredentialID string `json:"credential_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
un := strings.TrimSpace(body.Username)
if un == "" || !usernameRegexp.MatchString(un) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid username"})
}
if !isAdmin(c) && un != currentUser(c) {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Not allowed"})
}
wantID, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(body.CredentialID))
if err != nil || len(wantID) == 0 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid credential id"})
}
u, err := db.GetUserByName(un)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
found := false
for _, pc := range u.Passkeys {
if len(pc.ID) > 0 && bytes.Equal(pc.ID, wantID) {
found = true
break
}
}
if !found {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Credential not found"})
}
idKey := base64.RawURLEncoding.EncodeToString(wantID)
if u.PasskeyLabels == nil {
u.PasskeyLabels = map[string]string{}
}
u.PasskeyLabels[idKey] = strings.TrimSpace(body.Name)
if err := db.SaveUser(u); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
if un == currentUser(c) {
setUser(c, u.Username, u.Admin, util.GetDBUserCRC32(u))
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Passkey renamed"})
}
}
const passkeyDiscoverableSessionKey = "passkey_login_discoverable"
// passkeyAssertionAllowList builds allowCredentials for username-based login.
// Credentials registered in some browsers omit transports in storage; Credential Manager on Android then
// may not surface them for synced/platform passkeys unless we widen allowed transports when empty.
func passkeyAssertionAllowList(creds []webauthn.Credential) []protocol.CredentialDescriptor {
if len(creds) == 0 {
return nil
}
whenEmpty := []protocol.AuthenticatorTransport{
protocol.Internal,
protocol.Hybrid,
protocol.USB,
protocol.NFC,
protocol.BLE,
}
out := make([]protocol.CredentialDescriptor, 0, len(creds))
for i := range creds {
d := creds[i].Descriptor()
if len(d.Transport) == 0 {
d.Transport = whenEmpty
}
out = append(out, d)
}
return out
}
func discoverableUserLookup(db store.IStore) func(rawID, userHandle []byte) (webauthn.User, error) {
return func(rawID, userHandle []byte) (webauthn.User, error) {
users, err := db.GetUsers()
if err != nil {
return nil, err
}
for _, mu := range users {
for _, pc := range mu.Passkeys {
if len(pc.ID) > 0 && bytes.Equal(pc.ID, rawID) {
return webAuthnUser{mu}, nil
}
}
}
return nil, fmt.Errorf("credential not found")
}
}
func PasskeyBeginLogin(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled"})
}
data := make(map[string]interface{})
if err := c.Bind(&data); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
username, _ := data["username"].(string)
username = strings.TrimSpace(username)
wa, err := webauthnForRequest(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
sess, _ := session.Get("session", c)
token := xid.New().String()
if username == "" {
// Discoverable / passkey-only login (no username).
opts, sessionData, err := wa.BeginDiscoverableLogin()
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
passkeyLoginMu.Lock()
passkeyLoginSessions[token] = *sessionData
passkeyLoginMu.Unlock()
sess.Values[passkeyDiscoverableSessionKey] = token
_ = sess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, map[string]interface{}{"status": true, "discoverable": true, "options": opts})
}
if !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
u, err := db.GetUserByName(username)
if err != nil {
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
}
if len(u.Passkeys) == 0 {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "No passkey registered for this user"})
}
opts, sessionData, err := wa.BeginLogin(
webAuthnUser{u},
webauthn.WithAllowedCredentials(passkeyAssertionAllowList(u.Passkeys)),
)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
passkeyLoginMu.Lock()
passkeyLoginSessions[token] = *sessionData
passkeyLoginMu.Unlock()
sess.Values["passkey_login_"+username] = token
_ = sess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, map[string]interface{}{"status": true, "discoverable": false, "username": username, "options": opts})
}
}
func PasskeyFinishLogin(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
globalSettings, _ := db.GetGlobalSettings()
if !globalSettings.TOTPEnabled {
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Passkeys are disabled"})
}
// Read body for username/rememberMe, then restore it: c.Bind() would drain the body and
// wa.FinishLogin would get an empty request → "Parse error for Assertion".
bodyBytes, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
c.Request().Body = io.NopCloser(bytes.NewReader(bodyBytes))
var loginMeta struct {
Username string `json:"username"`
RememberMe bool `json:"rememberMe"`
}
if err := json.Unmarshal(bodyBytes, &loginMeta); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
username := strings.TrimSpace(loginMeta.Username)
rememberMe := loginMeta.RememberMe
wa, err := webauthnForRequest(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
sess, _ := session.Get("session", c)
if username == "" {
token, _ := sess.Values[passkeyDiscoverableSessionKey].(string)
if token == "" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Login session not found"})
}
passkeyLoginMu.Lock()
sessionData, ok := passkeyLoginSessions[token]
passkeyLoginMu.Unlock()
if !ok {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Login session expired"})
}
wUser, cred, err := wa.FinishPasskeyLogin(discoverableUserLookup(db), sessionData, c.Request())
if err != nil {
log.Warnf("[passkeys] discoverable login finish rejected: %v", err)
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid passkey"})
}
wu, ok := wUser.(webAuthnUser)
if !ok {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid user state"})
}
u := wu.u
if u.Disabled {
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Cuenta deshabilitada"})
}
if err := setLoginSession(c, u, rememberMe, globalSettings.SessionTimeoutMinutes); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot set session: %v", err)})
}
freshSess, _ := session.Get("session", c)
if cred != nil && len(cred.ID) > 0 {
freshSess.Values[sessionPkLoginCredKey] = base64.RawURLEncoding.EncodeToString(cred.ID)
}
delete(freshSess.Values, passkeyDiscoverableSessionKey)
passkeyLoginMu.Lock()
delete(passkeyLoginSessions, token)
passkeyLoginMu.Unlock()
_ = freshSess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
}
if !usernameRegexp.MatchString(username) {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
u, err := db.GetUserByName(username)
if err != nil {
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
}
if u.Disabled {
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Cuenta deshabilitada"})
}
token, _ := sess.Values["passkey_login_"+username].(string)
if token == "" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Login session not found"})
}
passkeyLoginMu.Lock()
sessionData, ok := passkeyLoginSessions[token]
passkeyLoginMu.Unlock()
if !ok {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Login session expired"})
}
cred, err := wa.FinishLogin(webAuthnUser{u}, sessionData, c.Request())
if err != nil {
log.Warnf("[passkeys] login finish rejected for %s: %v", username, err)
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid passkey"})
}
if err := setLoginSession(c, u, rememberMe, globalSettings.SessionTimeoutMinutes); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot set session: %v", err)})
}
freshSess, _ := session.Get("session", c)
if cred != nil && len(cred.ID) > 0 {
freshSess.Values[sessionPkLoginCredKey] = base64.RawURLEncoding.EncodeToString(cred.ID)
}
delete(freshSess.Values, "passkey_login_"+username)
passkeyLoginMu.Lock()
delete(passkeyLoginSessions, token)
passkeyLoginMu.Unlock()
_ = freshSess.Save(c.Request(), c.Response())
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
}
}