package api import ( "net/http" "time" "github.com/loyaly/behavision-server/internal/auth" ) func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` Device string `json:"device"` } if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } email := auth.NormalizeEmail(body.Email) // Two limiters, at very different sizes. Per-account stops somebody working // through a password list against one known address; per-IP is a much // looser backstop against spraying one guess across many addresses, because // an entire shop shares a single NAT address and a tight limit there locks // out the whole staff when one person mistypes. perUser, perIP := s.throttles() ipKey, userKey := clientIP(r), email if !perIP.Allow(ipKey) || !perUser.Allow(userKey) { writeErr(w, http.StatusTooManyRequests, "too_many_attempts", "Too many sign-in attempts. Wait a few minutes and try again.") return } rec, err := s.Store.UserByEmail(r.Context(), email) if err != nil { s.serverError(w, "login lookup", err) return } // Verify unconditionally, against a dummy hash when the address is unknown. // Returning early on "no such user" makes login response time a membership // oracle for your customer's staff directory. hash := rec.PasswordHash if !rec.Found || !rec.Active || hash == "" { hash = auth.DummyHash } ok := auth.VerifyPassword(hash, body.Password) if !ok || !rec.Found || !rec.Active { perIP.Fail(ipKey) perUser.Fail(userKey) // One message for every failure. "No such account" and "wrong password" // are the same answer to anyone who is not already the account holder. writeErr(w, http.StatusUnauthorized, "bad_credentials", "Email or password is incorrect.") return } // Cleared on success, so one forgotten password in the morning does not // lock a shop out at lunchtime. perIP.Reset(ipKey) perUser.Reset(userKey) sess, err := s.mint(r, rec, body.Device) if err != nil { s.serverError(w, "create session", err) return } if err := s.Store.TouchUserLogin(r.Context(), rec.ID); err != nil { // Not fatal. Failing a successful login because a bookkeeping column // would not update locks people out for nothing. s.logf("WARN could not record last_login for %s: %v", rec.ID, err) } s.Store.Audit(r.Context(), AuditEntry{ ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user", Action: "auth.login", Entity: "session", Detail: map[string]any{"device": trim(body.Device)}, }) writeJSON(w, http.StatusOK, sess) } func (s *Server) mint(r *http.Request, rec UserRecord, device string) (Session, error) { access, err := auth.NewToken() if err != nil { return Session{}, err } refresh, err := auth.NewToken() if err != nil { return Session{}, err } now := s.now() ns := NewSession{ UserID: rec.ID, ClientID: rec.ClientID, AccessHash: access.Hash, RefreshHash: refresh.Hash, AccessExpiry: now.Add(auth.AccessTTL), RefreshExp: now.Add(auth.RefreshTTL), Device: clip(trim(device), 120), } if err := s.Store.CreateSession(r.Context(), ns); err != nil { return Session{}, err } return Session{ Token: access.Plain, RefreshToken: refresh.Plain, ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339), User: User{ ID: rec.ID, Email: rec.Email, FullName: rec.FullName, Role: rec.Role, ClientID: rec.ClientID, Client: rec.ClientName, }, }, nil } // handleRefresh swaps a refresh token for a new pair. // // The old refresh token is invalidated in the same statement that issues the // new one. Leaving it usable would mean a token copied off a resold shop PC // keeps working forever alongside the real one. func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { var body struct { RefreshToken string `json:"refresh_token"` Device string `json:"device"` } if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } if trim(body.RefreshToken) == "" { badRequest(w, "refresh_token is required") return } p, expires, err := s.Store.SessionByRefresh(r.Context(), auth.HashToken(body.RefreshToken)) if err != nil { unauthorized(w, "Please sign in again.") return } if s.now().After(expires) { unauthorized(w, "Please sign in again.") return } access, err := auth.NewToken() if err != nil { s.serverError(w, "refresh mint", err) return } refresh, err := auth.NewToken() if err != nil { s.serverError(w, "refresh mint", err) return } now := s.now() ns := NewSession{ UserID: p.UserID, ClientID: p.ClientID, AccessHash: access.Hash, RefreshHash: refresh.Hash, AccessExpiry: now.Add(auth.AccessTTL), // The refresh window slides. A shop PC that is used every day never has // to be logged in again; one left in a cupboard for two months does. RefreshExp: now.Add(auth.RefreshTTL), Device: clip(trim(body.Device), 120), } if err := s.Store.RotateSession(r.Context(), p.SessionID, ns); err != nil { s.serverError(w, "rotate session", err) return } writeJSON(w, http.StatusOK, Session{ Token: access.Plain, RefreshToken: refresh.Plain, ExpiresAt: ns.AccessExpiry.UTC().Format(time.RFC3339), User: User{ ID: p.UserID, Email: p.Email, FullName: p.FullName, Role: p.Role, ClientID: p.ClientID, Client: p.ClientName, }, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if err := s.Store.RevokeSession(r.Context(), p.SessionID); err != nil { s.serverError(w, "revoke session", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "auth.logout", Entity: "session", EntityID: p.SessionID, }) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) writeJSON(w, http.StatusOK, User{ ID: p.UserID, Email: p.Email, FullName: p.FullName, Role: p.Role, ClientID: p.ClientID, Client: p.ClientName, }) } func clip(s string, n int) string { if len(s) > n { return s[:n] } return s }