249 lines
8.0 KiB
Go
249 lines
8.0 KiB
Go
package webui
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
// webauthnSessionCookie carries the SessionData between a WebAuthn ceremony's Begin
|
|
// and Finish steps — short-lived, httponly, holds no secret beyond the challenge
|
|
// itself (which is meaningless without the matching authenticator response).
|
|
const webauthnSessionCookie = "mailgoserver_webauthn_session"
|
|
|
|
// webauthnUser adapts an AdminUser + their stored credentials to webauthn.User.
|
|
type webauthnUser struct {
|
|
user *db.AdminUser
|
|
creds []db.WebAuthnCredential
|
|
}
|
|
|
|
func (u *webauthnUser) WebAuthnID() []byte {
|
|
sum := sha256.Sum256([]byte("admin-" + strconv.FormatInt(u.user.ID, 10)))
|
|
return sum[:]
|
|
}
|
|
func (u *webauthnUser) WebAuthnName() string { return u.user.Username }
|
|
func (u *webauthnUser) WebAuthnDisplayName() string { return u.user.Username }
|
|
func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential {
|
|
out := make([]webauthn.Credential, 0, len(u.creds))
|
|
for _, c := range u.creds {
|
|
var cred webauthn.Credential
|
|
if err := json.Unmarshal([]byte(c.CredentialData), &cred); err == nil {
|
|
out = append(out, cred)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) webauthnUserFor(user *db.AdminUser) (*webauthnUser, error) {
|
|
creds, err := a.DB.ListWebAuthnCredentials(user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &webauthnUser{user: user, creds: creds}, nil
|
|
}
|
|
|
|
func (a *App) buildWebAuthn() (*webauthn.WebAuthn, error) {
|
|
sec := a.Cfg.Section("Auth")
|
|
return webauthn.New(&webauthn.Config{
|
|
RPID: sec.Key("rp_id").MustString("localhost"),
|
|
RPDisplayName: sec.Key("rp_display_name").MustString("mailgoserver"),
|
|
RPOrigins: []string{sec.Key("rp_origin").MustString("http://localhost:5000")},
|
|
})
|
|
}
|
|
|
|
func saveWebauthnSession(w http.ResponseWriter, s *webauthn.SessionData) error {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: webauthnSessionCookie, Value: base64.URLEncoding.EncodeToString(b),
|
|
Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 5 * 60,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func loadWebauthnSession(r *http.Request) (*webauthn.SessionData, error) {
|
|
c, err := r.Cookie(webauthnSessionCookie)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := base64.URLEncoding.DecodeString(c.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s webauthn.SessionData
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func clearWebauthnSession(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{Name: webauthnSessionCookie, Value: "", Path: "/", MaxAge: -1})
|
|
}
|
|
|
|
// passkeyRegisterBegin starts enrolling a new passkey for the logged-in admin.
|
|
func (a *App) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
|
user := userFromContext(r)
|
|
wa, err := a.buildWebAuthn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "WebAuthn is not configured correctly: " + err.Error()})
|
|
return
|
|
}
|
|
wu, err := a.webauthnUserFor(user)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
|
return
|
|
}
|
|
creation, session, err := wa.BeginRegistration(wu)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := saveWebauthnSession(w, session); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start registration"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, creation)
|
|
}
|
|
|
|
// passkeyRegisterFinish completes enrollment and stores the new credential.
|
|
func (a *App) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
|
user := userFromContext(r)
|
|
wa, err := a.buildWebAuthn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
|
return
|
|
}
|
|
session, err := loadWebauthnSession(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, M{"error": "Registration session expired — try again"})
|
|
return
|
|
}
|
|
wu, err := a.webauthnUserFor(user)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
|
return
|
|
}
|
|
cred, err := wa.FinishRegistration(wu, *session, r)
|
|
clearWebauthnSession(w)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, M{"error": err.Error()})
|
|
return
|
|
}
|
|
data, err := json.Marshal(cred)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
|
|
return
|
|
}
|
|
name := r.URL.Query().Get("name")
|
|
if name == "" {
|
|
name = "Passkey"
|
|
}
|
|
if err := a.DB.CreateWebAuthnCredential(user.ID, name, base64.URLEncoding.EncodeToString(cred.ID), string(data)); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not save passkey"})
|
|
return
|
|
}
|
|
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey added: "+name)
|
|
writeJSON(w, http.StatusOK, M{"success": true})
|
|
}
|
|
|
|
func (a *App) passkeyRemove(w http.ResponseWriter, r *http.Request) {
|
|
user := userFromContext(r)
|
|
if err := a.DB.DeleteWebAuthnCredential(pathID(r), user.ID); err != nil {
|
|
setFlash(w, "error", "Could not remove passkey")
|
|
} else {
|
|
_ = a.DB.LogAuthAttempt("admin_mfa", user.Username, requestIP(r), true, "Passkey removed")
|
|
setFlash(w, "success", "Passkey removed")
|
|
}
|
|
http.Redirect(w, r, Prefix+"/account", http.StatusFound)
|
|
}
|
|
|
|
// passkeyLoginBegin starts the passkey ceremony for the user who's already passed
|
|
// their password and is now at the MFA step.
|
|
func (a *App) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
|
userID := pendingMFAUserID(r)
|
|
if userID == 0 {
|
|
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
|
return
|
|
}
|
|
user, err := a.DB.GetAdminUserByID(userID)
|
|
if err != nil || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
|
return
|
|
}
|
|
wa, err := a.buildWebAuthn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
|
return
|
|
}
|
|
wu, err := a.webauthnUserFor(user)
|
|
if err != nil || len(wu.creds) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, M{"error": "No passkeys registered"})
|
|
return
|
|
}
|
|
assertion, session, err := wa.BeginLogin(wu)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := saveWebauthnSession(w, session); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start login"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, assertion)
|
|
}
|
|
|
|
// passkeyLoginFinish verifies the assertion and, on success, promotes the pending
|
|
// login into a fully-verified session — the same outcome as a correct TOTP code.
|
|
func (a *App) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
|
userID := pendingMFAUserID(r)
|
|
if userID == 0 {
|
|
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
|
return
|
|
}
|
|
user, err := a.DB.GetAdminUserByID(userID)
|
|
if err != nil || user == nil {
|
|
writeJSON(w, http.StatusUnauthorized, M{"error": "No login in progress"})
|
|
return
|
|
}
|
|
wa, err := a.buildWebAuthn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": err.Error()})
|
|
return
|
|
}
|
|
session, err := loadWebauthnSession(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, M{"error": "Login session expired — try again"})
|
|
return
|
|
}
|
|
wu, err := a.webauthnUserFor(user)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not load account"})
|
|
return
|
|
}
|
|
if _, err := wa.FinishLogin(wu, *session, r); err != nil {
|
|
clearWebauthnSession(w)
|
|
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Passkey verification failed")
|
|
writeJSON(w, http.StatusUnauthorized, M{"error": "Passkey verification failed"})
|
|
return
|
|
}
|
|
clearWebauthnSession(w)
|
|
|
|
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"error": "Could not start session"})
|
|
return
|
|
}
|
|
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (passkey)")
|
|
clearPendingMFACookie(w)
|
|
setSessionCookie(w, token, r.TLS != nil)
|
|
writeJSON(w, http.StatusOK, M{"success": true})
|
|
}
|