feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую) - единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required, perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5) - cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS; альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1) - ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup, 2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст - /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth) - тесты: регистрация через API с cookie, ошибки входа, обязательная сессия, валидация, наличие всех путей в OpenAPI
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
|
||||
const sessionCookieName = "__Host-session"
|
||||
|
||||
const sessionCookiePath = "/"
|
||||
|
||||
// sessionCookie собирает cookie сессии: HttpOnly, SameSite=Lax и Secure при TLS.
|
||||
// Secure выключается только для установок без TLS (--skip-tls, стенд за туннелем):
|
||||
// в этом режиме браузер не принимает Secure-cookie по http.
|
||||
func (s *Server) sessionCookie(token string, expires time.Time) *http.Cookie {
|
||||
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expires.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie() *http.Cookie {
|
||||
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.TLSEnabled,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки
|
||||
// выставляются напрямую, а контракт описан в OpenAPI (docs.go).
|
||||
func (s *Server) registerAuthRoutes(router chi.Router) {
|
||||
router.Post("/auth/register", s.handleRegister)
|
||||
router.Post("/auth/login", s.handleLogin)
|
||||
router.Post("/auth/logout", s.handleLogout)
|
||||
router.Post("/auth/logout-all", s.handleLogoutAll)
|
||||
router.Get("/auth/sessions", s.handleListSessions)
|
||||
router.Post("/auth/step-up", s.handleStepUp)
|
||||
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
|
||||
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
|
||||
router.Get("/users/@me", s.handleGetMe)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type currentUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
Status string `json:"status"`
|
||||
CustomStatus string `json:"custom_status"`
|
||||
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||
BannerFileID string `json:"banner_file_id,omitempty"`
|
||||
IsInstanceAdmin bool `json:"is_instance_admin"`
|
||||
Badges []string `json:"badges"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
func userPayload(user *store.User) currentUserPayload {
|
||||
payload := currentUserPayload{
|
||||
ID: formatSnowflake(user.ID),
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Bio: user.Bio,
|
||||
Status: user.Status,
|
||||
CustomStatus: user.CustomStatus,
|
||||
IsInstanceAdmin: user.IsInstanceAdmin,
|
||||
Badges: user.Badges,
|
||||
Locale: user.Locale,
|
||||
}
|
||||
if payload.Badges == nil {
|
||||
payload.Badges = []string{}
|
||||
}
|
||||
if user.AvatarFileID != nil {
|
||||
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
|
||||
}
|
||||
if user.BannerFileID != nil {
|
||||
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
var request registerRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
user, token, session, err := s.auth.Register(r.Context(), auth.RegisterInput{
|
||||
Username: request.Username,
|
||||
DisplayName: request.DisplayName,
|
||||
Email: request.Email,
|
||||
Password: request.Password,
|
||||
Locale: request.Locale,
|
||||
IP: httpx.ClientIPFromContext(r.Context()),
|
||||
UserAgent: httpx.UserAgentFromContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return
|
||||
}
|
||||
var request loginRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
user, token, session, err := s.auth.Login(r.Context(), auth.LoginInput{
|
||||
Email: request.Email,
|
||||
Password: request.Password,
|
||||
TOTPCode: request.TOTPCode,
|
||||
IP: httpx.ClientIPFromContext(r.Context()),
|
||||
UserAgent: httpx.UserAgentFromContext(r.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1).
|
||||
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) {
|
||||
if s.auth == nil {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return nil, nil, false
|
||||
}
|
||||
token := ""
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
if token == "" {
|
||||
token = normalizeBearer(r.Header.Get("Authorization"))
|
||||
}
|
||||
if token == "" {
|
||||
writeAPIError(w, auth.ErrSessionExpired)
|
||||
return nil, nil, false
|
||||
}
|
||||
user, session, err := s.auth.ResolveSession(r.Context(), token)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
return user, session, true
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
_, session, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
// Выход без валидной сессии не ошибка: cookie всё равно очищаем.
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
if err := s.auth.Logout(r.Context(), session.ID); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.auth.LogoutAll(r.Context(), user.ID); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.clearSessionCookie())
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
ID string `json:"id"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Current bool `json:"current"`
|
||||
SteppedUp bool `json:"stepped_up"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
user, current, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sessions, err := s.store.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
payload := make([]sessionPayload, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
payload = append(payload, sessionPayload{
|
||||
ID: formatSnowflake(session.ID),
|
||||
UserAgent: session.UserAgent,
|
||||
IP: session.IP,
|
||||
CreatedAt: session.CreatedAt.Format(time.RFC3339),
|
||||
LastSeen: session.LastSeen.Format(time.RFC3339),
|
||||
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
|
||||
Current: session.ID == current.ID,
|
||||
SteppedUp: session.SteppedUp(now),
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"sessions": payload})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"user": userPayload(user)})
|
||||
}
|
||||
|
||||
type totpEnableRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
setup, err := s.auth.SetupTOTP(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"secret": setup.Secret,
|
||||
"url": setup.URL,
|
||||
"issuer": setup.IssuerName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user, _, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request totpEnableRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
codes, err := s.auth.EnableTOTP(r.Context(), user.ID, request.Code)
|
||||
if err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
type stepUpRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
|
||||
user, session, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request stepUpRequest
|
||||
if !decodeBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// formatSnowflake сериализует идентификатор строкой: JS не хранит uint64
|
||||
// без потери точности (AGENT.md 8.1).
|
||||
func formatSnowflake(id uint64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for id > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + id%10)
|
||||
id /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
Reference in New Issue
Block a user