180 lines
6.6 KiB
Go
180 lines
6.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
|
|
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
|
|
)
|
|
|
|
const CookieName = "bell_session"
|
|
|
|
type HTTP struct {
|
|
Service Service
|
|
Store Store
|
|
Audit audit.Store
|
|
SecureCookie bool
|
|
}
|
|
|
|
func (h HTTP) Register(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /api/v1/auth/login", h.login)
|
|
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
|
|
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
|
|
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
|
|
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
|
|
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
|
|
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
|
|
}
|
|
|
|
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
|
|
var input LoginInput
|
|
if err := decodeJSON(w, r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
result, err := h.Service.Login(r.Context(), input)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, ErrInvalidCredentials) {
|
|
status = http.StatusUnauthorized
|
|
}
|
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
|
|
result.User.PasswordHash = ""
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
|
|
}
|
|
|
|
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, principal(r.Context()))
|
|
}
|
|
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
|
|
cookie, _ := r.Cookie(CookieName)
|
|
token := ""
|
|
if cookie != nil {
|
|
token = cookie.Value
|
|
}
|
|
user := principal(r.Context())
|
|
if err := h.Service.Logout(r.Context(), token, user); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
|
|
return
|
|
}
|
|
h.setCookie(w, "", time.Unix(0, 0))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
|
|
users, err := h.Store.ListUsers(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": users})
|
|
}
|
|
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := decodeJSON(w, r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
|
if input.Username == "" || input.DisplayName == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
|
|
return
|
|
}
|
|
hash, err := hashPassword(input.Password)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
|
|
return
|
|
}
|
|
actor := principal(r.Context())
|
|
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
|
|
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
|
|
}
|
|
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
Roles []string `json:"roles"`
|
|
}
|
|
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
|
|
return
|
|
}
|
|
actor := principal(r.Context())
|
|
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
entries, err := h.Audit.List(r.Context(), limit)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
|
|
}
|
|
|
|
func (h HTTP) authenticated(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(CookieName)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
|
return
|
|
}
|
|
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
|
|
})
|
|
}
|
|
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
|
|
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user := principal(r.Context())
|
|
if !user.Permissions.Has(permission) {
|
|
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
}))
|
|
}
|
|
|
|
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
|
|
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
|
}
|
|
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
|
|
func Principal(ctx context.Context) User { return principal(ctx) }
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
return decoder.Decode(value)
|
|
}
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|