247 lines
8.2 KiB
Go
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
|
|
}
|