feat(api): authentication — login, two-step TOTP, invites and resets
Sessions are stored as hashes, so a database dump contains nothing that can be pasted into a browser. A login that has passed the password but not the second factor is modelled as a pending session: it expires, is revocable, and reaches exactly one endpoint. Signup is invite-only and takes the email address from the invite rather than the form, so a link cannot be redirected to somebody else. The first admin is created by an endpoint that refuses once any account exists, checked and inserted in one transaction because it is necessarily unauthenticated. Login failures and forgotten-password requests answer identically whether or not the address has an account — otherwise the form is an account-existence oracle. Password rules are a length floor and nothing else: composition rules push people towards worse passwords. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8251e69155
commit
1bb644f4d1
@@ -0,0 +1,460 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// userJSON is what the API says about a person. It deliberately does not carry the
|
||||
// password hash or the TOTP secret: those are not "fields that happen to be
|
||||
// private", they are values that must not leave the process, and the way to
|
||||
// guarantee that is for the response type not to have them.
|
||||
type userJSON struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
Timezone string `json:"timezone"`
|
||||
Locale string `json:"locale"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
return userJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
User *userJSON `json:"user,omitempty"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
||||
// One message for "no such account" and for "wrong password". Telling them
|
||||
// apart turns the login form into a way to enumerate who has an account
|
||||
// here, which for a private instance is most of what an attacker wants.
|
||||
//
|
||||
// The timing still differs — a missing user skips the Argon2 work — and
|
||||
// closing that would mean hashing against a dummy on every miss. Not done:
|
||||
// it costs 19 MiB per probe and hands over an easy way to exhaust memory.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that email address and password do not match an account")
|
||||
return
|
||||
}
|
||||
|
||||
// Argon2 parameters get raised over time; a login is the only moment the
|
||||
// plaintext exists and an upgrade is possible.
|
||||
if auth.NeedsRehash(user.PasswordHash) {
|
||||
if hash, err := auth.HashPassword(req.Password); err == nil {
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, ""); err != nil {
|
||||
s.log.Warn("rehash password", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
ttl := s.cfg.SessionTTL
|
||||
if user.TOTPEnabled {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// handleLoginTOTP completes a two-step login. It runs behind requirePendingSession,
|
||||
// so it is reachable only by a session that has already passed the password step.
|
||||
func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
// different form while locked out of their account is a poor time to
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
}
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, "promote session", err)
|
||||
return
|
||||
}
|
||||
// The cookie is reissued so its lifetime matches the session's new one.
|
||||
if cookie, cerr := r.Cookie(s.cookieName()); cerr == nil {
|
||||
s.setSessionCookie(w, cookie.Value, s.cfg.SessionTTL)
|
||||
}
|
||||
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if session := sessionFrom(r.Context()); session != nil {
|
||||
if err := s.db.DeleteSession(r.Context(), session.ID); err != nil {
|
||||
s.log.Warn("delete session", "err", err)
|
||||
}
|
||||
}
|
||||
s.clearSessionCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, toUserJSON(userFrom(r.Context())))
|
||||
}
|
||||
|
||||
// --- creating accounts --------------------------------------------------------
|
||||
|
||||
type signupRequest struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
// handleSignup creates an account from an invite. There is no open registration:
|
||||
// the email address comes from the invite, not from the form, so an invite cannot
|
||||
// be redirected to somebody else.
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var req signupRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
invite, err := s.db.InviteByToken(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"this invite link is not valid or has expired")
|
||||
return
|
||||
}
|
||||
|
||||
fields := map[string]string{}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
fields["name"] = "required"
|
||||
}
|
||||
if msg := checkPassword(req.Password); msg != "" {
|
||||
fields["password"] = msg
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := &store.User{
|
||||
Email: invite.Email, Name: name, PasswordHash: hash,
|
||||
Timezone: req.Timezone, Locale: req.Locale,
|
||||
}
|
||||
if err := s.db.CreateUser(r.Context(), user, inboxName(req.Locale)); err != nil {
|
||||
if errors.Is(err, store.ErrEmailInUse) {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"an account already exists for that email address")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Accepting the invite is what adds the membership and burns the token, in one
|
||||
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
||||
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
||||
s.internal(w, "accept invite", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.startSession(w, r, user)
|
||||
}
|
||||
|
||||
type bootstrapRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
}
|
||||
|
||||
// handleBootstrap creates the first administrator, and only ever the first: the
|
||||
// endpoint refuses once any account exists. That is what makes it safe to leave
|
||||
// unauthenticated — it has to be, because there is nobody to authenticate as yet.
|
||||
//
|
||||
// The race matters. Two requests arriving together must not both see an empty
|
||||
// database, so the check and the insert happen in one transaction inside the store.
|
||||
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
var req bootstrapRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fields := map[string]string{}
|
||||
if _, err := mail.ParseAddress(req.Email); err != nil {
|
||||
fields["email"] = "must be an email address"
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
fields["name"] = "required"
|
||||
}
|
||||
if msg := checkPassword(req.Password); msg != "" {
|
||||
fields["password"] = msg
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := &store.User{
|
||||
Email: req.Email, Name: strings.TrimSpace(req.Name), PasswordHash: hash,
|
||||
IsAdmin: true, Timezone: req.Timezone, Locale: req.Locale,
|
||||
}
|
||||
if err := s.db.CreateFirstAdmin(r.Context(), user, inboxName(req.Locale)); err != nil {
|
||||
if errors.Is(err, store.ErrAlreadySetUp) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create first admin", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("first administrator created", "user", user.ID, "email", user.Email)
|
||||
s.startSession(w, r, user)
|
||||
}
|
||||
|
||||
// handleSetupState tells the sign-in page whether to offer setup or a login form.
|
||||
// It reveals only whether any account exists, which anyone can determine anyway by
|
||||
// looking at whether the setup form works.
|
||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.UserCount(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusCreated, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
// --- password reset -----------------------------------------------------------
|
||||
|
||||
type forgotRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// handleForgotPassword always answers the same way.
|
||||
//
|
||||
// Saying "no account with that address" would turn this into an account-existence
|
||||
// oracle that needs no password at all. The cost is that somebody who mistypes
|
||||
// their address waits for an email that never comes, which is the better failure.
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req forgotRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err == nil {
|
||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create password reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||
if err := s.mail.SendPasswordReset(r.Context(), user.Email, user.Name, link, s.cfg.ResetTTL); err != nil {
|
||||
s.log.Error("send password reset", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "if that address has an account, a reset link is on its way",
|
||||
})
|
||||
}
|
||||
|
||||
type resetRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if msg := checkPassword(req.Password); msg != "" {
|
||||
writeFieldErrors(w, map[string]string{"password": msg})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := s.db.UsePasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"this reset link is not valid or has expired")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
// Every session ends: a password reset is what somebody does when they think
|
||||
// another person has been in their account.
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req changePasswordRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// The current password is required even though the caller is signed in: it is
|
||||
// what stops an unattended laptop from becoming a permanent account takeover.
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.CurrentPassword); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"current_password": "not correct"})
|
||||
return
|
||||
}
|
||||
if msg := checkPassword(req.NewPassword); msg != "" {
|
||||
writeFieldErrors(w, map[string]string{"new_password": msg})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
return
|
||||
}
|
||||
keep := ""
|
||||
if session := sessionFrom(r.Context()); session != nil {
|
||||
keep = session.ID
|
||||
}
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- validation ---------------------------------------------------------------
|
||||
|
||||
// Password rules, and only these.
|
||||
//
|
||||
// A length floor and nothing else is deliberate. Composition rules — a digit, a
|
||||
// symbol, a capital — measurably push people towards "Password1!" and are no longer
|
||||
// recommended by NIST or OWASP. Length is what actually helps, and a passphrase
|
||||
// must not be rejected for lacking punctuation.
|
||||
const (
|
||||
minPasswordLength = 10
|
||||
maxPasswordLength = 1024 // Argon2 will hash anything; this bounds the work
|
||||
)
|
||||
|
||||
func checkPassword(p string) string {
|
||||
switch {
|
||||
case p == "":
|
||||
return "required"
|
||||
case utf8.RuneCountInString(p) < minPasswordLength:
|
||||
return "must be at least 10 characters"
|
||||
case len(p) > maxPasswordLength:
|
||||
return "is too long"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inboxName is the one string that has to be right at account creation, before the
|
||||
// user has ever seen a settings page to state a preference on.
|
||||
func inboxName(locale string) string {
|
||||
if strings.HasPrefix(strings.ToLower(locale), "en") {
|
||||
return "Inbox"
|
||||
}
|
||||
return "Indbakke"
|
||||
}
|
||||
|
||||
// internal logs the real error and tells the client nothing about it. The detail
|
||||
// belongs in the log, where the operator can see it; in the response it would be a
|
||||
// description of the server's internals handed to whoever asked.
|
||||
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
|
||||
s.log.Error(what, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// testServer runs the real router against a real database over a real listener.
|
||||
// Auth is mostly cookies, status codes and middleware ordering — none of which a
|
||||
// unit test of the handler functions would exercise.
|
||||
type testServer struct {
|
||||
*httptest.Server
|
||||
db *store.DB
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://127.0.0.1",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
}
|
||||
// Discard: a passing test should print nothing, and these handlers log freely.
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
srv := httptest.NewServer(New(cfg, db, log, nil))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &testServer{
|
||||
Server: srv,
|
||||
db: db,
|
||||
// The cookie jar is what makes this a session test rather than a
|
||||
// sequence of unrelated requests.
|
||||
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *testServer) do(t *testing.T, method, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Same-origin, as a browser would report for the app's own fetches.
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
// bootstrap creates the first admin and leaves the client signed in.
|
||||
func (ts *testServer) bootstrap(t *testing.T) {
|
||||
t.Helper()
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("bootstrap: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCreatesTheFirstAdminOnlyOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["needs_setup"] != true {
|
||||
t.Fatalf("a fresh instance should need setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("setup: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user == nil || user["is_admin"] != true {
|
||||
t.Errorf("the first account is not an admin: %v", body)
|
||||
}
|
||||
// The password hash must never appear in a response.
|
||||
if _, leaked := user["password_hash"]; leaked {
|
||||
t.Error("the response carries the password hash")
|
||||
}
|
||||
|
||||
// A second attempt must be refused, or anyone could add themselves as admin.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "someone@else.dk", "name": "Someone", "password": "et andet kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("second setup: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET", "/api/v1/auth/setup", nil)
|
||||
if body["needs_setup"] != false {
|
||||
t.Errorf("instance still reports needing setup: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupCreatesAnInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ts.db.InboxID(t.Context(), user.ID); err != nil {
|
||||
t.Errorf("no Inbox was created for the first user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndLogout(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Signed in from bootstrap.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["email"] != "kristian@example.dk" {
|
||||
t.Fatalf("me: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: status %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("me after logout: status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["totp_required"] != false {
|
||||
t.Errorf("login asked for a code with 2FA off: %v", body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after login: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The address is normalised, so one account cannot be reached under two spellings
|
||||
// and, worse, a second account cannot be created that shadows the first.
|
||||
func TestLoginIgnoresEmailCaseAndSpace(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
for _, email := range []string{
|
||||
"kristian@example.dk", "KRISTIAN@example.dk", " Kristian@Example.DK ",
|
||||
} {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": email, "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("login as %q: status %d", email, resp.StatusCode)
|
||||
}
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong password and an address with no account must be indistinguishable, or the
|
||||
// login form becomes a way to find out who has an account here.
|
||||
func TestLoginDoesNotRevealWhichAccountsExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
respWrong, bodyWrong := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "forkert kodeord",
|
||||
})
|
||||
respMissing, bodyMissing := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "findes-ikke@example.dk", "password": "forkert kodeord",
|
||||
})
|
||||
|
||||
if respWrong.StatusCode != respMissing.StatusCode {
|
||||
t.Errorf("status differs: %d vs %d", respWrong.StatusCode, respMissing.StatusCode)
|
||||
}
|
||||
if bodyWrong["error"] != bodyMissing["error"] || bodyWrong["code"] != bodyMissing["code"] {
|
||||
t.Errorf("message differs:\n wrong password: %v\n no account: %v", bodyWrong, bodyMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsHardened(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
// Read the cookie off the response, not out of the jar: Go's cookiejar keeps
|
||||
// only name and value, so every attribute checked here would come back unset
|
||||
// regardless of what the server actually sent.
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("setup: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == sessionCookieInsecure || c.Name == sessionCookieSecure {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
if session == nil {
|
||||
t.Fatal("no session cookie was set")
|
||||
}
|
||||
if !session.HttpOnly {
|
||||
t.Error("session cookie is readable from JavaScript")
|
||||
}
|
||||
if session.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("SameSite = %v, want Lax", session.SameSite)
|
||||
}
|
||||
if session.Path != "/" {
|
||||
t.Errorf("Path = %q, want /", session.Path)
|
||||
}
|
||||
|
||||
// The stored value must be a hash, never the cookie itself: a database dump
|
||||
// must not contain anything that can be pasted into a browser.
|
||||
var stored string
|
||||
err := ts.db.QueryRow(`SELECT id FROM sessions LIMIT 1`).Scan(&stored)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored == session.Value {
|
||||
t.Error("the session token is stored verbatim; it must be hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
secret, _ := body["secret"].(string)
|
||||
if secret == "" {
|
||||
t.Fatal("no secret returned")
|
||||
}
|
||||
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if me["totp_enabled"] != false {
|
||||
t.Error("2FA switched on before the code was confirmed")
|
||||
}
|
||||
|
||||
// Confirm with a real code.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
codes, _ := body["recovery_codes"].([]any)
|
||||
if len(codes) == 0 {
|
||||
t.Fatal("no recovery codes were issued")
|
||||
}
|
||||
|
||||
// Now a login is two steps.
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK || body["totp_required"] != true {
|
||||
t.Fatalf("login should ask for a code: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// The half-finished session must not reach anything.
|
||||
resp, body = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a pending session reached /me: %d", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeTOTPRequired {
|
||||
t.Errorf("code = %v, want %s", body["code"], CodeTOTPRequired)
|
||||
}
|
||||
|
||||
// A wrong code does not finish the login.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a wrong code was accepted: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The right one does.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after completing 2FA: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A recovery code is accepted in the same field as a TOTP code, and works once.
|
||||
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
})
|
||||
raw := body["recovery_codes"].([]any)
|
||||
recovery := raw[0].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("recovery code rejected: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The same code must not work a second time.
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("a recovery code worked twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
|
||||
"current_password": "det forkerte", "new_password": "et helt nyt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("wrong current password: status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
|
||||
"current_password": "et langt kodeord", "new_password": "et helt nyt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("change password: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The session that made the change survives; the old password does not.
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Error("changing the password logged out the session that did it")
|
||||
}
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Error("the old password still works")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordRulesRejectShortOnesOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "k@example.dk", "name": "K", "password": "kort",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("short password: status %d", resp.StatusCode)
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["password"] == nil {
|
||||
t.Errorf("the error does not name the password field: %v", body)
|
||||
}
|
||||
|
||||
// A long passphrase with no digits or symbols must be accepted: composition
|
||||
// rules push people towards worse passwords, not better ones.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
|
||||
"email": "k@example.dk", "name": "K", "password": "syv heste løber over marken",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("a long passphrase was rejected: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Asking to reset a password must not reveal whether the address has an account.
|
||||
func TestForgotPasswordAnswersTheSameEitherWay(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
respKnown, bodyKnown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
|
||||
"email": "kristian@example.dk",
|
||||
})
|
||||
respUnknown, bodyUnknown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
|
||||
"email": "findes-ikke@example.dk",
|
||||
})
|
||||
|
||||
if respKnown.StatusCode != respUnknown.StatusCode {
|
||||
t.Errorf("status differs: %d vs %d", respKnown.StatusCode, respUnknown.StatusCode)
|
||||
}
|
||||
if bodyKnown["status"] != bodyUnknown["status"] {
|
||||
t.Errorf("message differs: %v vs %v", bodyKnown, bodyUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetEndsEverySession(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := ts.db.CreatePasswordReset(t.Context(), user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
|
||||
"token": token, "password": "et helt nyt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reset: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Everything is signed out — a reset is what you do when you think somebody
|
||||
// else has been in your account.
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a session survived a password reset: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The link works exactly once.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
|
||||
"token": token, "password": "endnu et kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a reset link worked twice: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupRequiresAnInvite(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
|
||||
"token": "opfundet", "name": "Ubuden", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("signup without a valid invite: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := ts.db.CreateInvite(t.Context(), "ny@example.dk", "", store.RoleEditor, admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The address comes from the invite, not from the form — so an invite cannot
|
||||
// be redirected to somebody else by editing the request.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
|
||||
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("signup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
user, _ := body["user"].(map[string]any)
|
||||
if user["email"] != "ny@example.dk" {
|
||||
t.Errorf("email = %v, want the invited address", user["email"])
|
||||
}
|
||||
if user["is_admin"] != false {
|
||||
t.Error("an invited user was made an admin")
|
||||
}
|
||||
|
||||
// And the invite is spent.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
|
||||
"token": token, "name": "Endnu En", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Error("an invite link was used twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRequestsAreRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/auth/me"},
|
||||
{"POST", "/api/v1/auth/totp/setup"},
|
||||
{"POST", "/api/v1/auth/password/change"},
|
||||
{"GET", "/api/v1/recovery-codes"},
|
||||
} {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp, _ := ts.do(t, tc.method, tc.path, map[string]string{})
|
||||
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 401", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-site POST must not be able to act on somebody's session.
|
||||
func TestCrossSiteWritesAreRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/logout", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("cross-site POST: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
// And the session survived it.
|
||||
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
|
||||
t.Error("the cross-site request logged the user out anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedRequestBodies(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
|
||||
cases := []struct {
|
||||
name, body string
|
||||
contentType string
|
||||
wantStatus int
|
||||
}{
|
||||
{"empty", "", "application/json", http.StatusBadRequest},
|
||||
{"not json", "hejsa", "application/json", http.StatusBadRequest},
|
||||
{"wrong field type", `{"email": 42}`, "application/json", http.StatusBadRequest},
|
||||
{"unknown field", `{"email":"a@b.dk","sneaky":1}`, "application/json", http.StatusBadRequest},
|
||||
{"two objects", `{"email":"a@b.dk"}{"email":"c@d.dk"}`, "application/json", http.StatusBadRequest},
|
||||
{"wrong content type", `{"email":"a@b.dk"}`, "text/plain", http.StatusUnsupportedMediaType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/login", bytes.NewReader([]byte(tc.body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", tc.contentType)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tc.wantStatus {
|
||||
t.Errorf("status %d, want %d", resp.StatusCode, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated password guesses must stop being answered.
|
||||
func TestLoginIsRateLimited(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
var limited bool
|
||||
for i := 0; i < 30; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "gæt",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
if resp.Header.Get("Retry-After") == "" {
|
||||
t.Error("a rate-limited response does not say when to retry")
|
||||
}
|
||||
limited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !limited {
|
||||
t.Error("thirty wrong passwords in a row were all answered")
|
||||
}
|
||||
}
|
||||
|
||||
func codeFor(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
code, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
|
||||
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate code: %v", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// APIError is the one shape every failure takes. A client that has learned to read
|
||||
// it once can read all of them.
|
||||
//
|
||||
// `code` is a stable machine-readable string; `error` is English prose for a
|
||||
// developer reading a log. Neither is what the user sees — the frontend maps `code`
|
||||
// to a Danish or English message, because the server has no business deciding which
|
||||
// language somebody's browser is in.
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"error"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func (e APIError) Error() string { return e.Message }
|
||||
|
||||
// The codes the API can return. Kept in one list so the frontend's mapping can be
|
||||
// checked against it rather than discovered from whatever a handler happened to write.
|
||||
const (
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeValidation = "validation_failed"
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeTOTPRequired = "totp_required"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeConflict = "conflict"
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInternal = "internal_error"
|
||||
CodePayloadTooLarge = "payload_too_large"
|
||||
)
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, APIError{Code: code, Message: message})
|
||||
}
|
||||
|
||||
func writeFieldErrors(w http.ResponseWriter, fields map[string]string) {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, APIError{
|
||||
Code: CodeValidation,
|
||||
Message: "one or more fields are not valid",
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
// maxBodyBytes caps a JSON request body at 1 MiB. Task text and comments are small;
|
||||
// anything larger is either a mistake or an attempt to make the server allocate.
|
||||
// File uploads do not come through here.
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
// decodeJSON reads a request body strictly: unknown fields are an error rather than
|
||||
// being ignored.
|
||||
//
|
||||
// Silently dropping a field the client sent is the worst of the options — a typo in
|
||||
// `priorty` becomes "the API accepted my request and ignored half of it", which is
|
||||
// discovered much later and by the user rather than by the developer.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" {
|
||||
if mt := strings.TrimSpace(strings.Split(ct, ";")[0]); mt != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, CodeBadRequest,
|
||||
"request body must be application/json")
|
||||
return errors.New("wrong content type")
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &maxErr):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"request body is too large")
|
||||
case errors.Is(err, io.EOF):
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "request body is empty")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, jsonErrorMessage(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Exactly one JSON value, so a body with a second object appended cannot smuggle
|
||||
// anything past a handler that only reads the first.
|
||||
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"request body must contain a single JSON object")
|
||||
return errors.New("trailing data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonErrorMessage turns the standard library's decoder errors into something that
|
||||
// names the offending field, since "json: cannot unmarshal string into Go struct
|
||||
// field taskRequest.priority of type int" is not a sentence to put in an API.
|
||||
func jsonErrorMessage(err error) string {
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
if typeErr.Field != "" {
|
||||
return "field " + typeErr.Field + " has the wrong type"
|
||||
}
|
||||
return "a field has the wrong type"
|
||||
}
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return "request body is not valid JSON"
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "json: unknown field ") {
|
||||
return "unknown field " + strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
}
|
||||
return "request body could not be read"
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
// enforced by the browser: it refuses the cookie unless it is Secure, path=/ and
|
||||
// has no Domain — which means a subdomain cannot set a cookie that overwrites this
|
||||
// one. Over plain HTTP the prefix is invalid, so a local dev instance uses the
|
||||
// bare name.
|
||||
const (
|
||||
sessionCookieSecure = "__Host-verdande_session"
|
||||
sessionCookieInsecure = "verdande_session"
|
||||
)
|
||||
|
||||
func (s *Server) cookieName() string {
|
||||
if s.secureCookies() {
|
||||
return sessionCookieSecure
|
||||
}
|
||||
return sessionCookieInsecure
|
||||
}
|
||||
|
||||
func (s *Server) secureCookies() bool {
|
||||
return strings.HasPrefix(s.cfg.BaseURL, "https://")
|
||||
}
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cookieName(),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
// httpOnly: the frontend never reads this, so a cross-site script that
|
||||
// gets to run cannot exfiltrate the session.
|
||||
HttpOnly: true,
|
||||
Secure: s.secureCookies(),
|
||||
// Lax rather than Strict: Strict would mean following an invite link from
|
||||
// an email lands you logged out, which looks exactly like a broken invite.
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(ttl),
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.cookieName(),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.secureCookies(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// requireAuth rejects anything without a complete login.
|
||||
//
|
||||
// A session still waiting on its TOTP code is refused here with totp_required, so
|
||||
// the two-step login cannot be skipped by simply calling the endpoint you wanted.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
if session != nil && session.PendingTOTP {
|
||||
writeError(w, http.StatusUnauthorized, CodeTOTPRequired,
|
||||
"this login still needs its verification code")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUser, user)
|
||||
if session != nil {
|
||||
ctx = context.WithValue(ctx, ctxSession, session)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requirePendingSession guards the second step of a login, and nothing else.
|
||||
func (s *Server) requirePendingSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, err := s.authenticate(r)
|
||||
if err != nil || session == nil || !session.PendingTOTP {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "no login is in progress")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUser, user)
|
||||
ctx = context.WithValue(ctx, ctxSession, session)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u := userFrom(r.Context()); u == nil || !u.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "administrator access is required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticate resolves a request to a user, by session cookie or by API token.
|
||||
//
|
||||
// Both are accepted everywhere. An API token belongs to a person and carries their
|
||||
// permissions exactly — there is no separate service identity to reason about, and
|
||||
// a script therefore cannot reach anything its owner could not.
|
||||
func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) {
|
||||
if token := bearerToken(r); token != "" && auth.IsAPIToken(token) {
|
||||
user, err := s.db.UserByAPIToken(r.Context(), token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return user, nil, nil
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, nil, errors.New("no credentials")
|
||||
}
|
||||
session, user, err := s.db.SessionByToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func userFrom(ctx context.Context) *store.User {
|
||||
u, _ := ctx.Value(ctxUser).(*store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func sessionFrom(ctx context.Context) *store.Session {
|
||||
s, _ := ctx.Value(ctxSession).(*store.Session)
|
||||
return s
|
||||
}
|
||||
|
||||
// requireCSRF blocks cross-site form posts.
|
||||
//
|
||||
// SameSite=Lax already stops the browser sending the cookie on a cross-site POST,
|
||||
// which covers current browsers. This is the second lock: a state-changing request
|
||||
// must either be same-origin by its own headers or carry an API token, which a
|
||||
// cross-site form cannot set. Checked here rather than with a token in every form,
|
||||
// because a token the frontend has to remember to include is a token it will one
|
||||
// day forget.
|
||||
func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// A bearer token cannot be attached by a cross-site form or image tag.
|
||||
if bearerToken(r) != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
||||
switch r.Header.Get("Sec-Fetch-Site") {
|
||||
case "same-origin", "same-site", "none":
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
case "cross-site":
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
|
||||
// Older clients send no Sec-Fetch-Site; fall back to comparing Origin.
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !s.isOwnOrigin(origin) {
|
||||
writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) isOwnOrigin(origin string) bool {
|
||||
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
||||
return true
|
||||
}
|
||||
// A local dev frontend on another port talks to the API directly.
|
||||
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// limiter is a fixed-window counter, per key, held in memory.
|
||||
//
|
||||
// In memory is the right scope here: verdande is one process with one SQLite file
|
||||
// beneath it, so there is no second instance for a shared counter to coordinate
|
||||
// with. Resetting the limits on restart is a real weakness, and an acceptable one —
|
||||
// this exists to make online password guessing impractical, not to survive a
|
||||
// determined attacker who can also restart the server.
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
windows map[string]*window
|
||||
limit int
|
||||
period time.Duration
|
||||
}
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newLimiter(limit int, period time.Duration) *limiter {
|
||||
l := &limiter{windows: map[string]*window{}, limit: limit, period: period}
|
||||
go l.sweep()
|
||||
return l
|
||||
}
|
||||
|
||||
// allow records an attempt and reports whether it may proceed, with how long to
|
||||
// wait if not.
|
||||
func (l *limiter) allow(key string) (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
w, ok := l.windows[key]
|
||||
if !ok || now.After(w.until) {
|
||||
l.windows[key] = &window{count: 1, until: now.Add(l.period)}
|
||||
return true, 0
|
||||
}
|
||||
if w.count >= l.limit {
|
||||
return false, time.Until(w.until)
|
||||
}
|
||||
w.count++
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// reset clears a key. Called after a successful login, so somebody who mistyped
|
||||
// their password four times is not still rate-limited once they get it right.
|
||||
func (l *limiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.windows, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// sweep drops expired windows. Without it the map is a slow memory leak keyed by
|
||||
// every address that ever failed a login.
|
||||
func (l *limiter) sweep() {
|
||||
for range time.Tick(5 * time.Minute) {
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, w := range l.windows {
|
||||
if now.After(w.until) {
|
||||
delete(l.windows, k)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimit guards an endpoint by client address.
|
||||
func (s *Server) rateLimit(l *limiter, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ok, retry := l.allow(clientIP(r)); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many attempts; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP is the address the middleware chain has already resolved through any
|
||||
// proxy headers — behind a Cloudflare Tunnel that is the caller, not the tunnel.
|
||||
// The port is dropped so repeated attempts from one machine share a bucket.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func retryAfterSeconds(d time.Duration) string {
|
||||
secs := int(d.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
return itoa(secs)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [20]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
@@ -17,14 +17,23 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/mail"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
db *store.DB
|
||||
log *slog.Logger
|
||||
web fs.FS
|
||||
cfg *config.Config
|
||||
db *store.DB
|
||||
log *slog.Logger
|
||||
web fs.FS
|
||||
mail *mail.Sender
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -32,7 +41,12 @@ type Server struct {
|
||||
// at build time; a nil value serves an explanatory placeholder instead so that a
|
||||
// backend-only build still runs and reports its health.
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{cfg: cfg, db: db, log: log, web: web}
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
@@ -47,9 +61,46 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Reachable without being signed in — these are how you become signed in.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/setup", s.handleSetupState)
|
||||
r.Method(http.MethodPost, "/setup",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBootstrap)))
|
||||
r.Method(http.MethodPost, "/login",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLogin)))
|
||||
r.Method(http.MethodPost, "/signup",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleSignup)))
|
||||
r.Method(http.MethodPost, "/password/forgot",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleForgotPassword)))
|
||||
r.Method(http.MethodPost, "/password/reset",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||
|
||||
// The second step of a login: a session that has passed the password
|
||||
// but not yet the code, and can reach nothing else.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requirePendingSession)
|
||||
r.Method(http.MethodPost, "/login/totp",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLoginTOTP)))
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(s.serveWeb)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
|
||||
// TOTP enrolment is three steps on purpose: begin, confirm, and only then is it on.
|
||||
//
|
||||
// Turning it on the moment a secret is generated would let somebody lock themselves
|
||||
// out by closing the tab before their authenticator app had actually stored it. The
|
||||
// secret is written to the user row but `totp_enabled` stays 0 until a code proves
|
||||
// the app has it.
|
||||
|
||||
type totpSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
if user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"two-factor authentication is already switched on")
|
||||
return
|
||||
}
|
||||
|
||||
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||
if err != nil {
|
||||
s.internal(w, "generate totp secret", err)
|
||||
return
|
||||
}
|
||||
// Stored but not enabled: confirm decides that.
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
||||
s.internal(w, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
}
|
||||
|
||||
type totpConfirmResponse struct {
|
||||
RecoveryCodes []string `json:"recovery_codes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if user.TOTPSecret == "" {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"start setting up two-factor authentication first")
|
||||
return
|
||||
}
|
||||
if err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now()); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"code": "not correct"})
|
||||
return
|
||||
}
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
||||
s.internal(w, "enable totp", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Shown once. The UI has to make that clear at this point, because there is no
|
||||
// way to show them again — only the hashes were kept.
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
}
|
||||
|
||||
type totpDisableRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// handleTOTPDisable requires the password again. Switching off a second factor is
|
||||
// exactly the action an attacker with a borrowed session would take first.
|
||||
func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpDisableRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
||||
s.internal(w, "disable totp", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||
s.log.Warn("clear recovery codes", "err", err, "user", user.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRecoveryCodesRegenerate issues a fresh set and retires the old one.
|
||||
func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Request) {
|
||||
var req totpDisableRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"two-factor authentication is not switched on")
|
||||
return
|
||||
}
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||
return
|
||||
}
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
}
|
||||
|
||||
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
||||
}
|
||||
|
||||
// issuer is what an authenticator app lists the entry under. It is the instance's
|
||||
// hostname rather than a bare "verdande", so somebody running two of them can tell
|
||||
// the two entries apart in a list of a dozen.
|
||||
func (s *Server) issuer() string {
|
||||
host := s.cfg.BaseURL
|
||||
for _, prefix := range []string{"https://", "http://"} {
|
||||
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
|
||||
host = host[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
return "verdande"
|
||||
}
|
||||
return "verdande (" + host + ")"
|
||||
}
|
||||
Reference in New Issue
Block a user