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 }