Initial commit
This commit is contained in:
246
server/internal/httpapi/auth.go
Normal file
246
server/internal/httpapi/auth.go
Normal file
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user