package http import ( "crypto/subtle" "encoding/json" stdhttp "net/http" "time" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) const ( sessionCookieName = "rm_session" sessionTTL = 24 * time.Hour bootstrapCookie = "rm_bootstrap_used" ) type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } type loginResponse struct { UserID string `json:"user_id"` Role string `json:"role"` } func (s *Server) handleLogin(w stdhttp.ResponseWriter, r *stdhttp.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } u, err := s.authenticateAndSession(w, r, req.Username, req.Password) if err != nil { writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_credentials", "") return } writeJSON(w, stdhttp.StatusOK, loginResponse{UserID: u.ID, Role: string(u.Role)}) } // authenticateAndSession verifies credentials, mints a session cookie, // records the login + audit, and returns the user. Any failure // (unknown user, wrong password, db error) is collapsed into a single // error — the caller decides how to surface it. Shared by JSON and // HTML login flows. func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Request, username, password string, ) (*store.User, error) { u, err := s.deps.Store.GetUserByUsername(r.Context(), username) if err != nil { // Same response for unknown user vs bad password — don't leak // existence to a probing attacker. return nil, errInvalidCredentials } if u.AuthSource == "oidc" { return nil, errInvalidCredentials } if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { return nil, errInvalidCredentials } if u.DisabledAt != nil { return nil, errInvalidCredentials } token, err := auth.NewToken() if err != nil { return nil, err } now := time.Now().UTC() sess := store.Session{ UserID: u.ID, CreatedAt: now, ExpiresAt: now.Add(sessionTTL), IP: r.RemoteAddr, UA: r.UserAgent(), } if err := s.deps.Store.CreateSession(r.Context(), sess, auth.HashToken(token)); err != nil { return nil, err } _ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now) stdhttp.SetCookie(w, &stdhttp.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, Expires: sess.ExpiresAt, }) _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "auth.login", TS: now, }) return u, nil } // errInvalidCredentials is the sentinel returned by // authenticateAndSession for any failure that maps to a 401 in HTTP. var errInvalidCredentials = errAuth("invalid_credentials") type errAuth string func (e errAuth) Error() string { return string(e) } func (s *Server) handleLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) { if c, err := r.Cookie(sessionCookieName); err == nil { _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) } stdhttp.SetCookie(w, &stdhttp.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.deps.Cfg.CookieSecure, SameSite: stdhttp.SameSiteLaxMode, }) w.WriteHeader(stdhttp.StatusNoContent) } type bootstrapRequest struct { Token string `json:"token"` Username string `json:"username"` Password string `json:"password"` } // handleBootstrap creates the first admin user. The endpoint accepts // the one-time token printed in the server logs on first run, and is // disabled the moment a user row exists. func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) { n, err := s.deps.Store.CountUsers(r.Context()) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } if n > 0 { writeJSONError(w, stdhttp.StatusConflict, "already_initialised", "a user already exists; bootstrap is disabled") return } if s.deps.BootstrapToken == "" { writeJSONError(w, stdhttp.StatusServiceUnavailable, "no_token", "bootstrap token not configured") return } var req bootstrapRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) return } // Constant-time compare keeps timing analysis off the table. if subtle.ConstantTimeCompare([]byte(req.Token), []byte(s.deps.BootstrapToken)) != 1 { writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token", "") return } if req.Username == "" || len(req.Password) < 12 { writeJSONError(w, stdhttp.StatusBadRequest, "weak_password", "password must be at least 12 characters") return } hash, err := auth.HashPassword(req.Password) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } u := store.User{ ID: ulid.Make().String(), Username: req.Username, PasswordHash: hash, Role: store.RoleAdmin, CreatedAt: time.Now().UTC(), } if err := s.deps.Store.CreateUser(r.Context(), u); err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "system", Action: "auth.bootstrap", TS: u.CreatedAt, }) writeJSON(w, stdhttp.StatusCreated, loginResponse{ UserID: u.ID, Role: string(u.Role), }) } // ----- json helpers -------------------------------------------------- type jsonError struct { Code string `json:"code"` Message string `json:"message,omitempty"` } func writeJSON(w stdhttp.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeJSONError(w stdhttp.ResponseWriter, status int, code, msg string) { writeJSON(w, status, jsonError{Code: code, Message: msg}) }