mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
131 lines
4.2 KiB
Go
131 lines
4.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/token"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/pkg/argon2"
|
|
)
|
|
|
|
type reauthRequest struct {
|
|
// Password is the account password. Optional for an account that has none
|
|
// (passkey or SSO only), which confirms with Code instead.
|
|
Password string `json:"password"`
|
|
// Code is a current TOTP or recovery code.
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type reauthResponse struct {
|
|
// ValidForSeconds is how long the confirmation lasts, so the client can
|
|
// decide whether to ask again rather than letting the next call fail.
|
|
ValidForSeconds int `json:"valid_for_seconds"`
|
|
}
|
|
|
|
// Reauth re-proves the account holder behind a live session.
|
|
//
|
|
// Sensitive changes (minting an API key, registering a passkey, transferring a
|
|
// workspace, scheduling a deletion) require a recent confirmation; this is
|
|
// where it is given. Either factor is accepted: an account with no password
|
|
// confirms with its authenticator, and an account with no 2FA confirms with its
|
|
// password.
|
|
func (h *Handler) Reauth(c *gin.Context) {
|
|
session := middleware.GetSession(c)
|
|
if session == nil {
|
|
errx.Handle(c, errx.ErrUnauthorized)
|
|
return
|
|
}
|
|
uid, err := uuid.Parse(middleware.GetUserID(c))
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req reauthRequest
|
|
if berr := c.ShouldBindJSON(&req); berr != nil {
|
|
errx.Handle(c, errx.InvalidBody(berr))
|
|
return
|
|
}
|
|
|
|
// An account created through Google, Apple or SSO has no password, and
|
|
// until it enrols 2FA it has no second factor either. There is nothing for
|
|
// it to confirm with, and silently accepting the live session instead would
|
|
// turn a stolen token into a permanent API key. Say what to do rather than
|
|
// refusing a credential the account does not have.
|
|
if !h.hasReauthFactor(c, uid) {
|
|
errx.Handle(c, errx.NewWithIdentifier(errx.BadRequest, "reauth_no_factor",
|
|
"This account has no password and no two-factor authentication, so there is nothing to confirm with. "+
|
|
"Turn on two-factor authentication under Settings > Security, or set a password, then try again."))
|
|
return
|
|
}
|
|
|
|
if req.Password == "" && req.Code == "" {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "provide your password or a two-factor code"))
|
|
return
|
|
}
|
|
|
|
// This endpoint checks a password, so it is a second place to guess one.
|
|
// Refused before the hash comparison, so a caller past the budget cannot
|
|
// measure Argon2's timing either.
|
|
ctx := c.Request.Context()
|
|
if !h.AuthService.ReserveReauthAttempt(ctx, uid) {
|
|
errx.Handle(c, errx.ErrAuthLimit)
|
|
return
|
|
}
|
|
|
|
// The reserved attempt stays charged on a miss.
|
|
if !h.reauthProofValid(c, uid, req) {
|
|
// One message for both factors: which one matched is not something an
|
|
// attacker holding a token should learn here.
|
|
errx.Handle(c, errx.ErrCredentials)
|
|
return
|
|
}
|
|
h.AuthService.ClearReauthFailures(ctx, uid)
|
|
|
|
if xerr := h.TokenService.StampReauth(c.Request.Context(), session.ID); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, reauthResponse{ValidForSeconds: int(token.ReauthWindow.Seconds())})
|
|
}
|
|
|
|
// hasReauthFactor reports whether the account has anything to re-authenticate
|
|
// with: a stored password, or an enrolled authenticator.
|
|
func (h *Handler) hasReauthFactor(c *gin.Context, uid uuid.UUID) bool {
|
|
ctx := c.Request.Context()
|
|
|
|
if h.TwoFAService != nil {
|
|
if enabled, err := h.TwoFAService.IsEnabled(ctx, uid); err == nil && enabled {
|
|
return true
|
|
}
|
|
}
|
|
hash, xerr := h.AuthService.PasswordHashFor(ctx, uid)
|
|
return xerr == nil && hash != ""
|
|
}
|
|
|
|
// reauthProofValid checks whichever factor the caller offered.
|
|
func (h *Handler) reauthProofValid(c *gin.Context, uid uuid.UUID, req reauthRequest) bool {
|
|
ctx := c.Request.Context()
|
|
|
|
if req.Code != "" && h.TwoFAService != nil {
|
|
if h.TwoFAService.VerifyCurrentCode(ctx, uid, req.Code) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if req.Password != "" {
|
|
hash, xerr := h.AuthService.PasswordHashFor(ctx, uid)
|
|
if xerr != nil || hash == "" {
|
|
return false
|
|
}
|
|
ok, verr := argon2.Verify(req.Password, hash)
|
|
return verr == nil && ok
|
|
}
|
|
|
|
return false
|
|
}
|