802 lines
27 KiB
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"})
|
|
}
|
|
}
|