Files
XQKqueue/server/internal/httpapi/auth.go
2026-07-12 15:53:24 +08:00

247 lines
8.2 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"calllinesystem/server/internal/model"
"calllinesystem/server/internal/security"
"gorm.io/gorm"
)
type principal struct {
User model.User
}
func (s *Server) requireAuthFor(role string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookieName := s.authCookieName(role)
cookie, err := r.Cookie(cookieName)
if err != nil || cookie.Value == "" {
writeError(w, &apiError{Status: http.StatusUnauthorized, Code: "AUTH_REQUIRED", Message: "请先登录"})
return
}
var session model.AuthSession
err = s.db.WithContext(r.Context()).
Preload("User").
Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", security.HashToken(cookie.Value), s.now()).
First(&session).Error
if errors.Is(err, gorm.ErrRecordNotFound) || err == nil && !session.User.Active {
clearSessionCookie(w, cookieName, s.config.SessionSecure)
writeError(w, &apiError{Status: http.StatusUnauthorized, Code: "SESSION_INVALID", Message: "登录已失效,请重新登录"})
return
}
if err != nil {
s.logger.Error("load auth session", "request_id", requestID(r.Context()), "error", err)
writeError(w, err)
return
}
if session.User.Role != role {
clearSessionCookie(w, cookieName, s.config.SessionSecure)
writeError(w, &apiError{Status: http.StatusForbidden, Code: "PORTAL_FORBIDDEN", Message: "该账号不能登录此端"})
return
}
ctx := context.WithValue(r.Context(), principalKey, principal{User: session.User})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) requireStaff(next http.Handler) http.Handler {
return s.requireAuthFor(model.RoleStaff, next)
}
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return s.requireAuthFor(model.RoleAdmin, next)
}
func (s *Server) authCookieName(role string) string {
if role == model.RoleAdmin {
return s.config.SessionCookieName + "_admin"
}
return s.config.SessionCookieName + "_staff"
}
func currentPrincipal(ctx context.Context) principal {
value, _ := ctx.Value(principalKey).(principal)
return value
}
func (s *Server) authorizeProject(ctx context.Context, projectID string) error {
user := currentPrincipal(ctx).User
if user.Role == model.RoleAdmin {
var count int64
if err := s.db.WithContext(ctx).Model(&model.Project{}).Where("id = ?", projectID).Count(&count).Error; err != nil {
return err
}
if count == 1 {
return nil
}
return &apiError{Status: http.StatusNotFound, Code: "PROJECT_NOT_FOUND", Message: "项目不存在"}
}
var count int64
if err := s.db.WithContext(ctx).Model(&model.UserProject{}).
Where("user_id = ? AND project_id = ?", user.ID, projectID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return &apiError{Status: http.StatusForbidden, Code: "PROJECT_FORBIDDEN", Message: "没有该项目的权限"}
}
return nil
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) loginFor(role string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input loginRequest
if err := decodeJSON(r, &input); err != nil {
writeError(w, err)
return
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
limiterKey := input.Username
if ip := remoteIP(r); ip != nil {
limiterKey += "|" + *ip
}
if allowed, retry := s.loginLimiter.allow(limiterKey); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds()))))
writeError(w, &apiError{Status: http.StatusTooManyRequests, Code: "LOGIN_RATE_LIMITED", Message: "登录尝试过多,请稍后再试"})
return
}
var user model.User
err := s.db.WithContext(r.Context()).Where("username = ?", input.Username).First(&user).Error
passwordHash := s.dummyPassword
if err == nil {
passwordHash = user.PasswordHash
}
valid := security.VerifyPassword(passwordHash, input.Password)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("login lookup", "request_id", requestID(r.Context()), "error", err)
writeError(w, err)
return
}
if err != nil || !valid || !user.Active || user.Role != role {
s.loginLimiter.failure(limiterKey)
writeError(w, &apiError{Status: http.StatusUnauthorized, Code: "INVALID_CREDENTIALS", Message: "账号、密码或登录入口不正确"})
return
}
token, err := security.GenerateToken()
if err != nil {
writeError(w, err)
return
}
now := s.now()
session := model.AuthSession{
UserID: user.ID, TokenHash: security.HashToken(token), ExpiresAt: now.Add(s.config.SessionTTL),
LastSeenAt: now, CreatedIP: remoteIP(r), UserAgent: boundedUserAgent(r), CreatedAt: now,
}
err = s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&session).Error; err != nil {
return err
}
return s.addAudit(tx, r, nil, &user.ID, "AUTH_LOGIN", "USER", &user.ID, map[string]any{"session_id": session.ID})
})
if err != nil {
writeError(w, err)
return
}
s.loginLimiter.success(limiterKey)
projects, err := s.projectsForUser(r.Context(), user)
if err != nil {
writeError(w, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: s.authCookieName(role), Value: token, Path: "/", HttpOnly: true,
Secure: s.config.SessionSecure, SameSite: http.SameSiteStrictMode,
Expires: session.ExpiresAt, MaxAge: int(s.config.SessionTTL.Seconds()),
})
writeJSON(w, http.StatusOK, map[string]any{"user": userView(user), "projects": projectViews(projects), "expires_at": session.ExpiresAt})
}
}
func (s *Server) logoutFor(role string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookieName := s.authCookieName(role)
cookie, _ := r.Cookie(cookieName)
now := s.now()
user := currentPrincipal(r.Context()).User
err := s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
if cookie != nil {
if err := tx.Model(&model.AuthSession{}).
Where("token_hash = ? AND revoked_at IS NULL", security.HashToken(cookie.Value)).
Update("revoked_at", now).Error; err != nil {
return err
}
}
return s.addAudit(tx, r, nil, &user.ID, "AUTH_LOGOUT", "USER", &user.ID, nil)
})
clearSessionCookie(w, cookieName, s.config.SessionSecure)
if err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func clearSessionCookie(w http.ResponseWriter, name string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", HttpOnly: true, Secure: secure,
SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0),
})
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
user := currentPrincipal(r.Context()).User
projects, err := s.projectsForUser(r.Context(), user)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": userView(user), "projects": projectViews(projects)})
}
func userView(user model.User) map[string]any {
return map[string]any{"id": user.ID, "username": user.Username, "display_name": user.Username, "role": user.Role}
}
func (s *Server) projectsForUser(ctx context.Context, user model.User) ([]model.Project, error) {
var projects []model.Project
query := s.db.WithContext(ctx).Model(&model.Project{}).Order("name ASC")
if user.Role != model.RoleAdmin {
query = query.Joins("JOIN user_projects ON user_projects.project_id = projects.id").
Where("user_projects.user_id = ?", user.ID)
}
err := query.Find(&projects).Error
return projects, err
}
func (s *Server) addAudit(tx *gorm.DB, r *http.Request, projectID, actorUserID *string, action, entityType string, entityID *string, details any) error {
body := json.RawMessage(`{}`)
if details != nil {
encoded, err := json.Marshal(details)
if err != nil {
return err
}
body = encoded
}
entry := model.AuditEntry{
ProjectID: projectID, ActorUserID: actorUserID, Action: action, EntityType: entityType,
EntityID: entityID, Details: body, RequestID: requestID(r.Context()), IPAddress: remoteIP(r),
UserAgent: boundedUserAgent(r), RetainUntil: s.now().AddDate(1, 0, 0), CreatedAt: s.now(),
}
return tx.Create(&entry).Error
}