package api import ( "encoding/json" "net/http" "time" "archivmail/internal/audit" ) const ( loginMaxFailures = 5 loginWindow = 15 * time.Minute ) func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // Rate-limiting: block after too many recent failures failures, err := s.users.CountRecentFailures(req.Username, loginWindow) if err == nil && failures >= loginMaxFailures { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, IPAddress: s.remoteIP(r), Success: false, Detail: "rate limited", }) writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later") return } token, user, totpRequired, err := s.authMgr.Login(req.Username, req.Password) if err != nil { _ = s.users.RecordLoginAttempt(req.Username, s.remoteIP(r)) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: req.Username, IPAddress: s.remoteIP(r), Success: false, Detail: classifyLoginError(err), }) writeError(w, http.StatusUnauthorized, "invalid credentials") return } // PROJ-24: If TOTP is enabled, return a pending token instead of a full session. if totpRequired { s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "totp_pending", }) writeJSON(w, http.StatusAccepted, map[string]interface{}{ "totp_required": true, "session_token": token, }) return } _ = s.users.UpdateLastLogin(user.ID) s.audlog.Log(audit.Entry{ EventType: audit.EventLogin, Username: user.Username, IPAddress: s.remoteIP(r), Success: true, }) http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", MaxAge: 8 * 3600, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: s.cfg.SecureCookies, }) writeJSON(w, http.StatusOK, map[string]interface{}{ "user": map[string]interface{}{ "id": user.ID, "username": user.Username, "email": user.Email, "role": user.Role, "list_page_size": user.ListPageSize, }, }) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) user, err := s.users.GetByUsername(sess.Username) if err != nil { writeError(w, http.StatusInternalServerError, "user lookup failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "username": user.Username, "email": user.Email, "role": user.Role, "list_page_size": user.ListPageSize, }) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { // Read token from cookie first, then Bearer header token := "" if c, err := r.Cookie(sessionCookieName); err == nil { token = c.Value } if token == "" { token = extractBearerToken(r) } if token != "" { _ = s.authMgr.Logout(token) } // Clear the session cookie http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: s.cfg.SecureCookies, }) sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: audit.EventLogout, Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }