930 lines
25 KiB
Go
930 lines
25 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha1"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/base32"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-webauthn/webauthn/protocol"
|
||
"github.com/go-webauthn/webauthn/webauthn"
|
||
)
|
||
|
||
// Two-step sign-in for the web interface: an authenticator app (TOTP) and
|
||
// passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
|
||
// passkey signs in on its own and also serves as the second step after a
|
||
// password. API tokens never need a second step.
|
||
//
|
||
// After a correct password, a user with two-step sign-in gets a short-lived
|
||
// ticket instead of a session; the ticket and a code or key turn into the
|
||
// session. A passkey signs in on its own, without username and password.
|
||
|
||
// UserMFA is a user's two-step sign-in setup, stored in config.json.
|
||
type UserMFA struct {
|
||
TOTPSecret string `json:"totpSecret,omitempty"` // base32
|
||
TOTPAdded *time.Time `json:"totpAdded,omitempty"`
|
||
Keys []MFAKey `json:"keys,omitempty"`
|
||
RecoveryCodes []string `json:"recoveryCodes,omitempty"` // SHA-256 of the unused codes
|
||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||
}
|
||
|
||
// MFAKey is a passkey.
|
||
type MFAKey struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
|
||
Created time.Time `json:"created"`
|
||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||
Credential webauthn.Credential `json:"credential"`
|
||
}
|
||
|
||
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
|
||
// passkeys are supported. A user left without a method loses their
|
||
// recovery codes too.
|
||
func dropSecurityKeys(u *User) {
|
||
if u.MFA == nil {
|
||
return
|
||
}
|
||
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
|
||
if !u.hasMFA() {
|
||
u.MFA.RecoveryCodes = nil
|
||
}
|
||
}
|
||
|
||
func (u *User) hasMFA() bool {
|
||
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
|
||
}
|
||
|
||
const (
|
||
ticketTTL = 5 * time.Minute
|
||
recoveryCount = 10
|
||
totpPeriod = 30
|
||
totpDigits = 6
|
||
maxKeyName = 64
|
||
)
|
||
|
||
// --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ---
|
||
|
||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||
|
||
func newTOTPSecret() string {
|
||
b := make([]byte, 20)
|
||
if _, err := rand.Read(b); err != nil {
|
||
panic(err)
|
||
}
|
||
return b32.EncodeToString(b)
|
||
}
|
||
|
||
func totpCode(key []byte, counter uint64) string {
|
||
var msg [8]byte
|
||
binary.BigEndian.PutUint64(msg[:], counter)
|
||
m := hmac.New(sha1.New, key)
|
||
m.Write(msg[:])
|
||
sum := m.Sum(nil)
|
||
off := sum[len(sum)-1] & 0x0f
|
||
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||
return fmt.Sprintf("%0*d", totpDigits, v%1_000_000)
|
||
}
|
||
|
||
// totpMatch returns the time step the code belongs to, allowing one step of
|
||
// clock drift either way.
|
||
func totpMatch(secret, code string, now time.Time) (uint64, bool) {
|
||
key, err := b32.DecodeString(strings.ToUpper(secret))
|
||
code = strings.Map(func(r rune) rune {
|
||
if r >= '0' && r <= '9' {
|
||
return r
|
||
}
|
||
return -1
|
||
}, code)
|
||
if err != nil || len(code) != totpDigits {
|
||
return 0, false
|
||
}
|
||
step := uint64(now.Unix() / totpPeriod)
|
||
for _, c := range []uint64{step, step - 1, step + 1} {
|
||
if subtle.ConstantTimeCompare([]byte(totpCode(key, c)), []byte(code)) == 1 {
|
||
return c, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func totpURI(secret, username string) string {
|
||
label := url.PathEscape(appName + ":" + username)
|
||
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + url.QueryEscape(appName) + "&algorithm=SHA1&digits=6&period=30"
|
||
}
|
||
|
||
// --- recovery codes ---
|
||
|
||
const recoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||
|
||
// newRecoveryCodes returns codes to show once and their hashes to store.
|
||
func newRecoveryCodes() (codes, hashes []string) {
|
||
for range recoveryCount {
|
||
b := make([]byte, 8)
|
||
if _, err := rand.Read(b); err != nil {
|
||
panic(err)
|
||
}
|
||
var s strings.Builder
|
||
for i, x := range b {
|
||
if i == 4 {
|
||
s.WriteByte('-')
|
||
}
|
||
s.WriteByte(recoveryAlphabet[int(x)%len(recoveryAlphabet)])
|
||
}
|
||
codes = append(codes, s.String())
|
||
hashes = append(hashes, hashRecovery(s.String()))
|
||
}
|
||
return codes, hashes
|
||
}
|
||
|
||
func hashRecovery(code string) string {
|
||
norm := strings.Map(func(r rune) rune {
|
||
if r == '-' || r == ' ' {
|
||
return -1
|
||
}
|
||
return r
|
||
}, strings.ToUpper(code))
|
||
sum := sha256.Sum256([]byte(norm))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// --- WebAuthn ---
|
||
|
||
// waUser adapts a User to the webauthn library.
|
||
type waUser struct{ u *User }
|
||
|
||
func (w waUser) WebAuthnID() []byte { return w.u.MFA.Handle }
|
||
func (w waUser) WebAuthnName() string { return w.u.Username }
|
||
func (w waUser) WebAuthnDisplayName() string { return w.u.Username }
|
||
func (w waUser) WebAuthnCredentials() []webauthn.Credential {
|
||
var out []webauthn.Credential
|
||
if w.u.MFA != nil {
|
||
for _, k := range w.u.MFA.Keys {
|
||
out = append(out, k.Credential)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// keysAvailable reports whether passkeys can work on this
|
||
// address: WebAuthn needs a domain name (not an IP address) and a
|
||
// certificate the browser trusts, or localhost.
|
||
func (a *App) keysAvailable(r *http.Request) bool {
|
||
host := hostOnly(r.Host)
|
||
if host == "localhost" {
|
||
return true
|
||
}
|
||
return host != "" && net.ParseIP(host) == nil && a.store.Get().Web.TLS.Mode != "selfsigned"
|
||
}
|
||
|
||
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
|
||
if !a.keysAvailable(r) {
|
||
return nil, badRequest("passkeys need a domain name with a trusted certificate")
|
||
}
|
||
scheme := "https"
|
||
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
|
||
scheme = "http"
|
||
}
|
||
return webauthn.New(&webauthn.Config{
|
||
RPID: hostOnly(r.Host), RPDisplayName: appName, RPOrigins: []string{scheme + "://" + r.Host},
|
||
})
|
||
}
|
||
|
||
// --- pending ceremonies, kept in memory ---
|
||
|
||
// ticket is a sign-in waiting for its second step.
|
||
type ticket struct {
|
||
userID string
|
||
ip string
|
||
expires time.Time
|
||
fails int
|
||
key *webauthn.SessionData // a passkey challenge, once asked for
|
||
}
|
||
|
||
type ceremony struct {
|
||
userID string // "" for a passkey sign-in
|
||
data *webauthn.SessionData
|
||
expires time.Time
|
||
}
|
||
|
||
type mfaState struct {
|
||
tickets map[string]*ticket
|
||
logins map[string]*ceremony // passkey sign-ins by id
|
||
enrolls map[string]*ceremony // key registrations by user ID
|
||
totpSetup map[string]string // TOTP secrets waiting for their first code, by user ID
|
||
totpLast map[string]uint64 // last time step used per user, so a code works once
|
||
}
|
||
|
||
func newMFAState() mfaState {
|
||
return mfaState{tickets: map[string]*ticket{}, logins: map[string]*ceremony{}, enrolls: map[string]*ceremony{},
|
||
totpSetup: map[string]string{}, totpLast: map[string]uint64{}}
|
||
}
|
||
|
||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||
|
||
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
|
||
// be held.
|
||
func (a *Auth) failLocked(ip string) {
|
||
f := a.fails[ip]
|
||
if f == nil {
|
||
f = &failState{}
|
||
a.fails[ip] = f
|
||
}
|
||
f.count++
|
||
if f.count >= maxFailures {
|
||
f.count = 0
|
||
f.until = time.Now().Add(lockoutTime)
|
||
}
|
||
}
|
||
|
||
func (a *Auth) lockedLocked(ip string) bool {
|
||
f := a.fails[ip]
|
||
return f != nil && time.Now().Before(f.until)
|
||
}
|
||
|
||
// newTicket starts the second step for a user whose password was right.
|
||
// a.mu must be held.
|
||
func (a *Auth) newTicketLocked(u *User, ip string) string {
|
||
id := randomString(32)
|
||
a.mfa.tickets[id] = &ticket{userID: u.ID, ip: ip, expires: time.Now().Add(ticketTTL)}
|
||
return id
|
||
}
|
||
|
||
// ticketUser returns the live ticket and its user.
|
||
func (a *Auth) ticketUser(id, ip string) (*ticket, *User, error) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if a.lockedLocked(ip) {
|
||
return nil, nil, errLocked
|
||
}
|
||
t := a.mfa.tickets[id]
|
||
if t == nil || time.Now().After(t.expires) {
|
||
delete(a.mfa.tickets, id)
|
||
return nil, nil, errBadTicket
|
||
}
|
||
_, u := a.store.Get().userByID(t.userID)
|
||
if u == nil {
|
||
delete(a.mfa.tickets, id)
|
||
return nil, nil, errBadTicket
|
||
}
|
||
return t, u, nil
|
||
}
|
||
|
||
// ticketFailed counts a wrong code; five end the ticket.
|
||
func (a *Auth) ticketFailed(id, ip string) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
a.failLocked(ip)
|
||
if t := a.mfa.tickets[id]; t != nil {
|
||
t.fails++
|
||
if t.fails >= maxFailures {
|
||
delete(a.mfa.tickets, id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// finishSignIn turns a passed second step into a session.
|
||
func (a *Auth) finishSignIn(u *User, ip string) string {
|
||
cfg := a.store.Get()
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
delete(a.fails, ip)
|
||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||
}
|
||
|
||
// --- sign-in endpoints (public) ---
|
||
|
||
func (a *App) signedIn(w http.ResponseWriter, r *http.Request, u *User, how string) {
|
||
ip := remoteIP(r)
|
||
a.setSessionCookie(w, r, a.auth.finishSignIn(u, ip))
|
||
slog.Info("login", "audit", true, "actor", u.Username, "remote", ip, "method", how)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
func (a *App) signInFailed(w http.ResponseWriter, err error) {
|
||
code := http.StatusUnauthorized
|
||
if errors.Is(err, errLocked) {
|
||
code = http.StatusTooManyRequests
|
||
}
|
||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||
}
|
||
|
||
// signInOptions tells the sign-in page whether to offer a passkey.
|
||
func (a *App) signInOptions(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]any{"passkeys": a.keysAvailable(r)})
|
||
}
|
||
|
||
func (a *App) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Ticket, Code string }
|
||
if err := readJSON(r, &in); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
ip := remoteIP(r)
|
||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||
if err != nil {
|
||
a.signInFailed(w, err)
|
||
return
|
||
}
|
||
if u.MFA == nil || u.MFA.TOTPSecret == "" || !a.auth.useTOTP(u.ID, u.MFA.TOTPSecret, in.Code) {
|
||
a.auth.ticketFailed(in.Ticket, ip)
|
||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong authenticator code")
|
||
a.signInFailed(w, errors.New("wrong code"))
|
||
return
|
||
}
|
||
a.auth.dropTicket(in.Ticket)
|
||
a.signedIn(w, r, u, "totp")
|
||
}
|
||
|
||
// useTOTP checks a code and makes sure it is not used twice.
|
||
func (a *Auth) useTOTP(userID, secret, code string) bool {
|
||
step, ok := totpMatch(secret, code, time.Now())
|
||
if !ok {
|
||
return false
|
||
}
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if last, seen := a.mfa.totpLast[userID]; seen && step <= last {
|
||
return false
|
||
}
|
||
a.mfa.totpLast[userID] = step
|
||
return true
|
||
}
|
||
|
||
// ticketUserID returns the ticket's user without checking the lockout.
|
||
func (a *Auth) ticketUserID(id string) (string, *User) {
|
||
a.mu.Lock()
|
||
t := a.mfa.tickets[id]
|
||
a.mu.Unlock()
|
||
if t == nil {
|
||
return "", nil
|
||
}
|
||
_, u := a.store.Get().userByID(t.userID)
|
||
return t.userID, u
|
||
}
|
||
|
||
// mfaMethods lists what the second step can use: "key", "totp", "recovery".
|
||
func mfaMethods(u *User) []string {
|
||
out := []string{}
|
||
if u == nil || u.MFA == nil {
|
||
return out
|
||
}
|
||
if len(u.MFA.Keys) > 0 {
|
||
out = append(out, "key")
|
||
}
|
||
if u.MFA.TOTPSecret != "" {
|
||
out = append(out, "totp")
|
||
}
|
||
if len(u.MFA.RecoveryCodes) > 0 {
|
||
out = append(out, "recovery")
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (a *Auth) dropTicket(id string) {
|
||
a.mu.Lock()
|
||
delete(a.mfa.tickets, id)
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Ticket, Code string }
|
||
if err := readJSON(r, &in); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
ip := remoteIP(r)
|
||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||
if err != nil {
|
||
a.signInFailed(w, err)
|
||
return
|
||
}
|
||
h := hashRecovery(in.Code)
|
||
var left int
|
||
used := false
|
||
_ = a.store.Update(func(c *Config) error {
|
||
_, cu := c.userByID(u.ID)
|
||
if cu == nil || cu.MFA == nil {
|
||
return nil
|
||
}
|
||
for i, x := range cu.MFA.RecoveryCodes {
|
||
if subtle.ConstantTimeCompare([]byte(x), []byte(h)) == 1 {
|
||
cu.MFA.RecoveryCodes = slices.Delete(cu.MFA.RecoveryCodes, i, i+1)
|
||
used = true
|
||
break
|
||
}
|
||
}
|
||
left = len(cu.MFA.RecoveryCodes)
|
||
return nil
|
||
})
|
||
if !used {
|
||
a.auth.ticketFailed(in.Ticket, ip)
|
||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong recovery code")
|
||
a.signInFailed(w, errors.New("wrong or used recovery code"))
|
||
return
|
||
}
|
||
a.auth.dropTicket(in.Ticket)
|
||
slog.Info("recovery code used", "audit", true, "actor", u.Username, "remote", ip, "left", left)
|
||
a.signedIn(w, r, u, "recovery code")
|
||
}
|
||
|
||
// loginKeyBegin asks for one of the user's passkeys, as the second step.
|
||
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Ticket string }
|
||
if err := readJSON(r, &in); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
t, u, err := a.auth.ticketUser(in.Ticket, remoteIP(r))
|
||
if err != nil {
|
||
a.signInFailed(w, err)
|
||
return
|
||
}
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
if u.MFA == nil || len(u.MFA.Keys) == 0 {
|
||
writeErr(w, badRequest("no passkey is set up"))
|
||
return
|
||
}
|
||
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.auth.mu.Lock()
|
||
t.key = data
|
||
a.auth.mu.Unlock()
|
||
writeJSON(w, http.StatusOK, opts)
|
||
}
|
||
|
||
// loginKeyFinish checks the key's answer. The ticket is in the query, the
|
||
// body is the browser's credential.
|
||
func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
|
||
id := r.URL.Query().Get("ticket")
|
||
ip := remoteIP(r)
|
||
t, u, err := a.auth.ticketUser(id, ip)
|
||
if err != nil {
|
||
a.signInFailed(w, err)
|
||
return
|
||
}
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.auth.mu.Lock()
|
||
data := t.key
|
||
t.key = nil
|
||
a.auth.mu.Unlock()
|
||
if data == nil {
|
||
writeErr(w, badRequest("ask for the key first"))
|
||
return
|
||
}
|
||
cred, err := wa.FinishLogin(waUser{u}, *data, r)
|
||
if err != nil {
|
||
a.auth.ticketFailed(id, ip)
|
||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
|
||
a.signInFailed(w, errors.New("the passkey was not accepted"))
|
||
return
|
||
}
|
||
a.keyUsed(u.ID, cred)
|
||
a.auth.dropTicket(id)
|
||
a.signedIn(w, r, u, "passkey")
|
||
}
|
||
|
||
// keyUsed stores the key's new signature counter and when it was used.
|
||
func (a *App) keyUsed(userID string, cred *webauthn.Credential) {
|
||
now := time.Now().UTC()
|
||
_ = a.store.Update(func(c *Config) error {
|
||
if _, u := c.userByID(userID); u != nil && u.MFA != nil {
|
||
for i := range u.MFA.Keys {
|
||
if k := &u.MFA.Keys[i]; bytes.Equal(k.Credential.ID, cred.ID) {
|
||
k.Credential.Authenticator = cred.Authenticator
|
||
k.Credential.Flags = cred.Flags
|
||
k.LastUsed = &now
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// loginPasskeyBegin starts a sign-in with a passkey alone.
|
||
func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
opts, data, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
id := randomString(24)
|
||
a.auth.mu.Lock()
|
||
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
|
||
a.auth.mu.Unlock()
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
||
}
|
||
|
||
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||
id := r.URL.Query().Get("id")
|
||
ip := remoteIP(r)
|
||
a.auth.mu.Lock()
|
||
cer := a.auth.mfa.logins[id]
|
||
delete(a.auth.mfa.logins, id)
|
||
locked := a.auth.lockedLocked(ip)
|
||
a.auth.mu.Unlock()
|
||
if locked {
|
||
a.signInFailed(w, errLocked)
|
||
return
|
||
}
|
||
if cer == nil || time.Now().After(cer.expires) {
|
||
a.signInFailed(w, errors.New("the sign-in expired; try again"))
|
||
return
|
||
}
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
cfg := a.store.Get()
|
||
var found *User
|
||
cred, err := wa.FinishDiscoverableLogin(func(rawID, handle []byte) (webauthn.User, error) {
|
||
for i := range cfg.Users {
|
||
u := &cfg.Users[i]
|
||
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
|
||
for _, k := range u.MFA.Keys {
|
||
if bytes.Equal(k.Credential.ID, rawID) {
|
||
found = u
|
||
return waUser{u}, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil, errors.New("unknown passkey")
|
||
}, *cer.data, r)
|
||
if err != nil || found == nil {
|
||
a.auth.mu.Lock()
|
||
a.auth.failLocked(ip)
|
||
a.auth.mu.Unlock()
|
||
slog.Warn("login failed", "remote", ip, "reason", "passkey not accepted")
|
||
a.signInFailed(w, errors.New("this passkey is not known here"))
|
||
return
|
||
}
|
||
a.keyUsed(found.ID, cred)
|
||
a.signedIn(w, r, found, "passkey")
|
||
}
|
||
|
||
// --- managing your own two-step sign-in (signed-in users) ---
|
||
|
||
type keyView struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Created time.Time `json:"created"`
|
||
LastUsed *time.Time `json:"lastUsed"`
|
||
}
|
||
|
||
func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
|
||
cfg := a.store.Get()
|
||
_, u := cfg.userByID(who(r).UserID)
|
||
if u == nil {
|
||
writeErr(w, badRequest("no such user"))
|
||
return
|
||
}
|
||
out := map[string]any{"totp": false, "totpAdded": nil, "keys": []keyView{}, "recoveryLeft": 0,
|
||
"keysAvailable": a.keysAvailable(r), "required": cfg.SignIn.RequireMFA}
|
||
if m := u.MFA; m != nil {
|
||
keys := []keyView{}
|
||
for _, k := range m.Keys {
|
||
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
|
||
}
|
||
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// addFirstCodes gives a user recovery codes with their first method. It
|
||
// returns the codes to show, or nil when the user already has codes. It runs
|
||
// inside a store update.
|
||
func addFirstCodes(u *User) []string {
|
||
if len(u.MFA.RecoveryCodes) > 0 {
|
||
return nil
|
||
}
|
||
codes, hashes := newRecoveryCodes()
|
||
u.MFA.RecoveryCodes = hashes
|
||
return codes
|
||
}
|
||
|
||
func (a *App) totpSetup(w http.ResponseWriter, r *http.Request) {
|
||
p := who(r)
|
||
secret := newTOTPSecret()
|
||
a.auth.mu.Lock()
|
||
a.auth.mfa.totpSetup[p.UserID] = secret
|
||
a.auth.mu.Unlock()
|
||
_, u := a.store.Get().userByID(p.UserID)
|
||
if u == nil {
|
||
writeErr(w, badRequest("no such user"))
|
||
return
|
||
}
|
||
uri := totpURI(secret, u.Username)
|
||
qr, _ := qrDataURL(uri)
|
||
writeJSON(w, http.StatusOK, map[string]any{"secret": secret, "uri": uri, "qr": qr})
|
||
}
|
||
|
||
func (a *App) totpConfirm(w http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Code string }
|
||
if err := readJSON(r, &in); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
p := who(r)
|
||
a.auth.mu.Lock()
|
||
secret := a.auth.mfa.totpSetup[p.UserID]
|
||
a.auth.mu.Unlock()
|
||
if secret == "" {
|
||
writeErr(w, badRequest("start the setup again"))
|
||
return
|
||
}
|
||
if !a.auth.useTOTP(p.UserID, secret, in.Code) {
|
||
writeErr(w, badRequest("wrong code; check the time on your phone and try the next one"))
|
||
return
|
||
}
|
||
var codes []string
|
||
now := time.Now().UTC()
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(p.UserID)
|
||
if u == nil {
|
||
return badRequest("no such user")
|
||
}
|
||
if u.MFA == nil {
|
||
u.MFA = &UserMFA{}
|
||
}
|
||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = secret, &now
|
||
codes = addFirstCodes(u)
|
||
return nil
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.auth.mu.Lock()
|
||
delete(a.auth.mfa.totpSetup, p.UserID)
|
||
a.auth.mu.Unlock()
|
||
a.audit(r, "authenticator app added")
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||
}
|
||
|
||
// lastMethodCheck refuses to remove the last method while two-step sign-in
|
||
// is required.
|
||
func lastMethodCheck(c *Config, u *User) error {
|
||
if c.SignIn.RequireMFA && !u.hasMFA() {
|
||
return badRequest("two-step sign-in is required here; add another method first")
|
||
}
|
||
if !u.hasMFA() && u.MFA != nil {
|
||
u.MFA.RecoveryCodes = nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
|
||
p := who(r)
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(p.UserID)
|
||
if u == nil || u.MFA == nil || u.MFA.TOTPSecret == "" {
|
||
return badRequest("no authenticator app is set up")
|
||
}
|
||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = "", nil
|
||
return lastMethodCheck(c, u)
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.audit(r, "authenticator app removed")
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// keyBegin starts adding a passkey.
|
||
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
p := who(r)
|
||
// The user handle is made once and never changes.
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(p.UserID)
|
||
if u == nil {
|
||
return badRequest("no such user")
|
||
}
|
||
if u.MFA == nil {
|
||
u.MFA = &UserMFA{}
|
||
}
|
||
if len(u.MFA.Handle) == 0 {
|
||
u.MFA.Handle = make([]byte, 32)
|
||
if _, err := rand.Read(u.MFA.Handle); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
_, u := a.store.Get().userByID(p.UserID)
|
||
var exclude []protocol.CredentialDescriptor
|
||
for _, k := range u.MFA.Keys {
|
||
exclude = append(exclude, k.Credential.Descriptor())
|
||
}
|
||
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
|
||
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.auth.mu.Lock()
|
||
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
|
||
a.auth.mu.Unlock()
|
||
writeJSON(w, http.StatusOK, opts)
|
||
}
|
||
|
||
// keyFinish stores the new key. The name is in the query, the body is the
|
||
// browser's credential.
|
||
func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||
p := who(r)
|
||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||
a.auth.mu.Lock()
|
||
cer := a.auth.mfa.enrolls[p.UserID]
|
||
delete(a.auth.mfa.enrolls, p.UserID)
|
||
a.auth.mu.Unlock()
|
||
if cer == nil || time.Now().After(cer.expires) {
|
||
writeErr(w, badRequest("adding the key took too long; try again"))
|
||
return
|
||
}
|
||
wa, err := a.webAuthn(r)
|
||
if err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
_, u := a.store.Get().userByID(p.UserID)
|
||
if u == nil {
|
||
writeErr(w, badRequest("no such user"))
|
||
return
|
||
}
|
||
cred, err := wa.FinishRegistration(waUser{u}, *cer.data, r)
|
||
if err != nil {
|
||
writeErr(w, badRequest("the key was not accepted: %v", err))
|
||
return
|
||
}
|
||
if name == "" {
|
||
name = "Passkey"
|
||
}
|
||
if len(name) > maxKeyName {
|
||
name = name[:maxKeyName]
|
||
}
|
||
var codes []string
|
||
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(p.UserID)
|
||
if u == nil || u.MFA == nil {
|
||
return badRequest("no such user")
|
||
}
|
||
u.MFA.Keys = append(u.MFA.Keys, key)
|
||
codes = addFirstCodes(u)
|
||
return nil
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.audit(r, "passkey added", "key", name)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||
}
|
||
|
||
func (a *App) keyRename(w http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Name string }
|
||
if err := readJSON(r, &in); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
if in.Name == "" || len(in.Name) > maxKeyName {
|
||
writeErr(w, badRequest("name must be 1–%d characters", maxKeyName))
|
||
return
|
||
}
|
||
id := r.PathValue("id")
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(who(r).UserID)
|
||
if u == nil || u.MFA == nil {
|
||
return badRequest("no such key")
|
||
}
|
||
for i := range u.MFA.Keys {
|
||
if u.MFA.Keys[i].ID == id {
|
||
u.MFA.Keys[i].Name = in.Name
|
||
return nil
|
||
}
|
||
}
|
||
return badRequest("no such key")
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
var name string
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(who(r).UserID)
|
||
if u == nil || u.MFA == nil {
|
||
return badRequest("no such key")
|
||
}
|
||
i := slices.IndexFunc(u.MFA.Keys, func(k MFAKey) bool { return k.ID == id })
|
||
if i < 0 {
|
||
return badRequest("no such key")
|
||
}
|
||
name = u.MFA.Keys[i].Name
|
||
u.MFA.Keys = slices.Delete(u.MFA.Keys, i, i+1)
|
||
return lastMethodCheck(c, u)
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.audit(r, "passkey removed", "key", name)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
func (a *App) newRecoveryCodesHandler(w http.ResponseWriter, r *http.Request) {
|
||
var codes []string
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(who(r).UserID)
|
||
if u == nil || !u.hasMFA() {
|
||
return badRequest("turn on two-step sign-in first")
|
||
}
|
||
var hashes []string
|
||
codes, hashes = newRecoveryCodes()
|
||
u.MFA.RecoveryCodes = hashes
|
||
return nil
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.audit(r, "recovery codes replaced")
|
||
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
|
||
}
|
||
|
||
// resetMFA removes another user's two-step sign-in, for a lost phone or key.
|
||
// Their user handle stays, so passkeys they still hold are just unknown.
|
||
func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
|
||
id := r.PathValue("id")
|
||
if id == who(r).UserID {
|
||
writeErr(w, badRequest("manage your own two-step sign-in under My account"))
|
||
return
|
||
}
|
||
var name string
|
||
if err := a.store.Update(func(c *Config) error {
|
||
_, u := c.userByID(id)
|
||
if u == nil {
|
||
return badRequest("no such user")
|
||
}
|
||
name = u.Username
|
||
if u.MFA != nil {
|
||
u.MFA = &UserMFA{Handle: u.MFA.Handle}
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
writeErr(w, err)
|
||
return
|
||
}
|
||
a.audit(r, "two-step sign-in reset", "user", name)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// mfaSummary is what user lists show.
|
||
func mfaSummary(u *User) map[string]any {
|
||
out := map[string]any{"totp": false, "passkeys": 0}
|
||
if m := u.MFA; m != nil {
|
||
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
|
||
}
|
||
return out
|
||
}
|