feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)

Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
ddcat666
2026-10-13 05:31:30 +08:00
committed by GitHub
parent e70fe1b9c9
commit 4c1d2b4012
83 changed files with 5836 additions and 328 deletions

View File

@@ -0,0 +1,64 @@
package handler
import (
"log"
"yingce/backend/internal/service"
"github.com/gin-gonic/gin"
)
func loginRequestContext(c *gin.Context) service.LoginContext {
return service.LoginContext{IP: c.ClientIP(), UserAgent: c.Request.UserAgent()}
}
// 补充 JSON 校验和频控等在认证域调用之前被拒绝的请求;不读取或保存原始请求正文。
func rejectedLoginAudit(svc *service.Service, method string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if c.Writer.Status() < 400 || c.GetBool("login_audit_handled") {
return
}
if err := svc.RecordRejectedLogin(method, loginRequestContext(c), c.Writer.Status()); err != nil {
log.Printf("rejected login audit write failed: method=%s status=%d error=%v", method, c.Writer.Status(), err)
}
}
}
func registerAdminLoginLogRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/admin/login-logs", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if err := svc.RequireAdmin(actor); err != nil {
failService(c, err)
return
}
page, limit, err := parsePaginationQuery(c, 20)
if err != nil {
fail(c, 400, err)
return
}
query := service.LoginLogQuery{Keyword: c.Query("keyword"), Event: c.Query("event"), Method: c.Query("method"), Result: c.Query("result"), IP: c.Query("ip"), From: c.Query("from"), To: c.Query("to")}
result, err := svc.AdminLoginLogs(actor, query, page, limit)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/admin/login-logs/:id", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
event, err := svc.AdminLoginLog(actor, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"log": event})
})
}

View File

@@ -22,6 +22,7 @@ import (
func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
registerChannelOrderRoutes(r, svc)
registerNotificationRoutes(r, svc)
registerAuthProfileRoutes(r, svc)
r.GET("/auth/settings", func(c *gin.Context) {
settings, err := svc.PublicAuthSettings()
if err != nil {
@@ -41,6 +42,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
if !available || !enforceRateLimit(c, "register:"+c.ClientIP(), policy.Request.RegisterPerHour, time.Hour) {
return
}
req.LoginContext = loginRequestContext(c)
result, err := svc.Register(req)
if err != nil {
failService(c, err)
@@ -113,7 +115,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"reset": true})
})
r.POST("/auth/login", func(c *gin.Context) {
r.POST("/auth/login", rejectedLoginAudit(svc, "password"), func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -132,6 +134,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+subject, policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
return
}
req.LoginContext = loginRequestContext(c)
c.Set("login_audit_handled", true)
result, err := svc.Login(req)
if err != nil {
failService(c, err)
@@ -152,9 +156,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
}
c.Redirect(http.StatusFound, target)
})
r.GET("/auth/linuxdo/callback", linuxDOCallbackHandler(svc))
r.GET("/auth/linuxdo/callback", rejectedLoginAudit(svc, "linuxdo"), linuxDOCallbackHandler(svc))
r.POST("/auth/logout", func(c *gin.Context) {
_ = svc.Logout(sessionCookie(c))
if err := svc.Logout(sessionCookie(c), loginRequestContext(c)); err != nil {
failService(c, err)
return
}
clearSessionCookie(c)
ok(c, gin.H{"ok": true})
})
@@ -207,7 +214,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
// 兼容已在 Linux.do OAuth 应用中登记的传统回调地址,处理逻辑与 /api/auth/linuxdo/callback 完全一致。
func RegisterOAuthCallbackRoutes(r gin.IRoutes, svc *service.Service) {
r.GET("/oauth/linuxdo/callback", linuxDOCallbackHandler(svc))
r.GET("/oauth/linuxdo/callback", rejectedLoginAudit(svc, "linuxdo"), linuxDOCallbackHandler(svc))
}
func linuxDOCallbackHandler(svc *service.Service) gin.HandlerFunc {
@@ -215,12 +222,27 @@ func linuxDOCallbackHandler(svc *service.Service) gin.HandlerFunc {
if !enforceRateLimit(c, "linuxdo-callback:"+c.ClientIP(), 30, 10*time.Minute) {
return
}
result, err := svc.CompleteLinuxDOLogin(c.Query("state"), c.Query("code"))
reauthentication, stateErr := svc.LinuxDOReauthenticationState(c.Query("state"))
if stateErr != nil {
if err := svc.RecordRejectedLogin("linuxdo", loginRequestContext(c), 400); err != nil {
log.Printf("OAuth rejected login audit write failed: error=%v", err)
}
c.Redirect(http.StatusFound, "/login?oauth_error="+url.QueryEscape("无法读取身份验证状态,请重试"))
return
}
c.Set("login_audit_handled", true)
result, err := svc.CompleteLinuxDOLogin(c.Query("state"), c.Query("code"), sessionCookie(c), loginRequestContext(c))
if err != nil {
if reauthentication {
c.Redirect(http.StatusFound, "/settings?section=profile&reauth=error")
return
}
c.Redirect(http.StatusFound, "/login?oauth_error="+url.QueryEscape(err.Error()))
return
}
setSessionCookie(c, result.Session.Session, result.Session.MaxAgeSecs)
if !result.Reauthenticated {
setSessionCookie(c, result.Session.Session, result.Session.MaxAgeSecs)
}
c.Redirect(http.StatusFound, result.Next)
}
}
@@ -230,6 +252,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
registerAdminChannelRoutes(r, svc)
registerAdminSettingRoutes(r, svc)
registerAdminAPILogRoutes(r, svc)
registerAdminLoginLogRoutes(r, svc)
}
func RegisterSystemProxyRoutes(r *gin.RouterGroup, svc *service.Service) {

View File

@@ -0,0 +1,165 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"yingce/backend/internal/model"
"yingce/backend/internal/service"
)
func registerAuthProfileRoutes(r *gin.RouterGroup, svc *service.Service) {
profile := r.Group("/auth/profile")
profile.Use(func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
c.Abort()
return
}
c.Set("profileActor", actor)
})
actor := func(c *gin.Context) *model.User { return c.MustGet("profileActor").(*model.User) }
securityWrite := func(c *gin.Context) bool {
policy, available := loadRuntimePolicy(c, svc)
return available && enforceRateLimit(c, "profile-security-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) &&
enforceRateLimit(c, "profile-security-user:"+actor(c).ID, policy.Request.LoginAccountPerTenMinutes, 10*time.Minute)
}
profile.GET("", func(c *gin.Context) {
result, err := svc.UserProfile(actor(c), sessionCookie(c))
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
profile.PUT("/avatar", func(c *gin.Context) {
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "profile-avatar:"+actor(c).ID, policy.Request.ResourceUploadPerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (5<<20)+(64<<10))
file, err := c.FormFile("file")
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
if err != nil {
failService(c, service.BadAuthRequest("请选择不超过 5 MiB 的 PNG 或 JPEG 头像"))
return
}
result, err := svc.UpdateProfileAvatar(actor(c), file)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"user": result})
})
profile.DELETE("/avatar", func(c *gin.Context) {
if !enforceRateLimit(c, "profile-avatar-default:"+actor(c).ID, 30, time.Minute) {
return
}
result, err := svc.UseDefaultProfileAvatar(actor(c))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"user": result})
})
profile.GET("/avatar/:userId", func(c *gin.Context) {
data, err := svc.ProfileAvatarContent(actor(c), c.Param("userId"))
if err != nil {
failService(c, err)
return
}
c.Header("Cache-Control", "private, no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, "image/png", data)
})
profile.POST("/avatar/default", func(c *gin.Context) {
if !enforceRateLimit(c, "profile-avatar-default:"+actor(c).ID, 30, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<10)
var req struct {
Preset string `json:"preset"`
}
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("系统头像选择参数无效"))
return
}
result, err := svc.SelectDefaultProfileAvatar(actor(c), req.Preset)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"user": result})
})
profile.POST("/password", func(c *gin.Context) {
if !securityWrite(c) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var req service.ProfilePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("密码修改参数无效"))
return
}
if err := svc.ChangeProfilePassword(actor(c), sessionCookie(c), req); err != nil {
failService(c, err)
return
}
clearSessionCookie(c)
ok(c, gin.H{"changed": true})
})
profile.POST("/email-code", func(c *gin.Context) {
if !securityWrite(c) {
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "profile-email-code:"+actor(c).ID, policy.Request.EmailCodePerHour, time.Hour) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var req service.ProfileEmailCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("邮箱验证参数无效"))
return
}
result, err := svc.SendProfileEmailCode(c.Request.Context(), actor(c), sessionCookie(c), req)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
profile.POST("/email", func(c *gin.Context) {
if !securityWrite(c) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var req service.ProfileEmailChangeRequest
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("邮箱修改参数无效"))
return
}
result, err := svc.ChangeProfileEmail(actor(c), sessionCookie(c), req)
if err != nil {
failService(c, err)
return
}
clearSessionCookie(c)
ok(c, result)
})
profile.GET("/linuxdo/start", func(c *gin.Context) {
if !enforceRateLimit(c, "profile-linuxdo-reauth:"+actor(c).ID, 10, 10*time.Minute) {
return
}
target, err := svc.BeginLinuxDOReauthentication(actor(c), sessionCookie(c))
if err != nil {
failService(c, err)
return
}
c.Redirect(http.StatusFound, target)
})
}

View File

@@ -0,0 +1,220 @@
package handler
import (
"bytes"
"encoding/json"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"yingce/backend/internal/auth"
"yingce/backend/internal/database"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"yingce/backend/internal/service"
)
func profileHandlerFixture(t *testing.T) (*gin.Engine, *gorm.DB, string) {
t.Helper()
gin.SetMode(gin.TestMode)
dir := t.TempDir()
db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "fixture.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.MigrateSchema(db); err != nil {
t.Fatal(err)
}
hash, err := auth.HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
user := model.User{ID: "profile-user", Username: "profile-user", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: hash}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.AuthSession{
{ID: "profile-session", UserID: user.ID, TokenHash: auth.HashToken("fixture-token"), ExpiresAt: time.Now().Add(time.Hour)},
{ID: "profile-other-session", UserID: user.ID, TokenHash: auth.HashToken("other-token"), ExpiresAt: time.Now().Add(time.Hour)},
}).Error; err != nil {
t.Fatal(err)
}
svc := service.New(repository.New(db), dir)
ConfigureRuntime(svc)
t.Cleanup(func() { ConfigureRuntime(nil); svc.Close(); sqlDB, _ := db.DB(); _ = sqlDB.Close() })
router := gin.New()
RegisterAuthRoutes(router.Group("/api"), svc)
return router, db, "profile-session.fixture-token"
}
func profileRequest(router http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if cookie != "" {
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie})
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestProfileRoutesRejectAnonymousBeforeMutation(t *testing.T) {
router, _, _ := profileHandlerFixture(t)
for _, route := range []struct{ method, path string }{
{"GET", "/api/auth/profile"}, {"PUT", "/api/auth/profile/avatar"},
{"DELETE", "/api/auth/profile/avatar"}, {"GET", "/api/auth/profile/avatar/profile-user"},
{"POST", "/api/auth/profile/avatar/default"},
{"POST", "/api/auth/profile/password"}, {"POST", "/api/auth/profile/email-code"},
{"POST", "/api/auth/profile/email"}, {"GET", "/api/auth/profile/linuxdo/start"},
} {
rec := profileRequest(router, route.method, route.path, "{}", "")
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s returned %d", route.method, route.path, rec.Code)
}
}
}
func TestProfilePasswordHTTPRevokesSessionsAndClearsCookie(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
wrong := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"wrong-password","newPassword":"new-password"}`, cookie)
if wrong.Code != 401 {
t.Fatalf("wrong password status %d: %s", wrong.Code, wrong.Body.String())
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 2 {
t.Fatalf("failed change revoked sessions: %d", count)
}
rec := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"old-password","newPassword":"new-password"}`, cookie)
if rec.Code != 200 {
t.Fatalf("change status %d: %s", rec.Code, rec.Body.String())
}
cleared := false
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatal("successful change did not clear login cookie")
}
var user model.User
db.First(&user, "id = ?", "profile-user")
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("new-password")) != nil {
t.Fatal("new password not persisted")
}
db.Model(&model.AuthSession{}).Count(&count)
if count != 0 {
t.Fatalf("sessions remain after change: %d", count)
}
if status := profileRequest(router, "GET", "/api/auth/profile", "", cookie).Code; status != 401 {
t.Fatalf("old cookie still accepted: %d", status)
}
}
func TestProfilePasswordRejectsOversizedUTF8WithoutRevokingSessions(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
body, err := json.Marshal(map[string]string{"currentPassword": "old-password", "newPassword": strings.Repeat("密", 25)})
if err != nil {
t.Fatal(err)
}
rec := profileRequest(router, "POST", "/api/auth/profile/password", string(body), cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("oversized UTF-8 password status %d, want 400", rec.Code)
}
var count int64
if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("invalid password revoked sessions: %d", count)
}
}
func TestProfileDefaultAvatarSelectionPersistsAndRejectsUnknownPreset(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
invalid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"unsupported"}`, cookie)
if invalid.Code != 400 {
t.Fatalf("unknown preset status %d", invalid.Code)
}
valid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"cat"}`, cookie)
if valid.Code != 200 {
t.Fatalf("selection status %d: %s", valid.Code, valid.Body.String())
}
var user model.User
if err := db.First(&user, "id = ?", "profile-user").Error; err != nil {
t.Fatal(err)
}
if user.AvatarMode != "default" || user.AvatarPreset != "cat" || user.AvatarVersion != "" {
t.Fatal("selected default was not persisted")
}
reloaded := profileRequest(router, "GET", "/api/auth/profile", "", cookie)
if reloaded.Code != 200 || !strings.Contains(reloaded.Body.String(), `"avatarPreset":"cat"`) {
t.Fatalf("reloaded profile lost selection: %d", reloaded.Code)
}
}
func TestProfileAvatarHTTPPersistsServesAndRestoresDefault(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
var body bytes.Buffer
form := multipart.NewWriter(&body)
part, err := form.CreateFormFile("file", "avatar.png")
if err != nil {
t.Fatal(err)
}
if err := png.Encode(part, image.NewNRGBA(image.Rect(0, 0, 40, 20))); err != nil {
t.Fatal(err)
}
if err := form.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("PUT", "/api/auth/profile/avatar", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String())
}
var envelope struct {
Data struct {
User struct {
AvatarURL string `json:"avatarUrl"`
} `json:"user"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(envelope.Data.User.AvatarURL, "/api/auth/profile/avatar/profile-user?v=") {
t.Fatalf("missing versioned avatar URL: %s", envelope.Data.User.AvatarURL)
}
content := profileRequest(router, "GET", envelope.Data.User.AvatarURL, "", cookie)
if content.Code != 200 || content.Header().Get("Content-Type") != "image/png" || content.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("avatar response %d %v", content.Code, content.Header())
}
if foreign := profileRequest(router, "GET", "/api/auth/profile/avatar/other-user", "", cookie); foreign.Code != 403 {
t.Fatalf("foreign avatar status %d", foreign.Code)
}
reset := profileRequest(router, "DELETE", "/api/auth/profile/avatar", "", cookie)
if reset.Code != 200 {
t.Fatalf("reset avatar status %d: %s", reset.Code, reset.Body.String())
}
var user model.User
db.First(&user, "id = ?", "profile-user")
if user.AvatarMode != "default" || user.AvatarVersion != "" {
t.Fatalf("default not persisted: %q", user.AvatarMode)
}
}

View File

@@ -37,7 +37,7 @@ func testLoginIdentityAttempts(t *testing.T, accounts []string) []string {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.SystemSetting{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.LoginLog{}, &model.SystemSetting{}); err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()

View File

@@ -0,0 +1,178 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"yingce/backend/internal/service"
)
func TestLoginLogsPasswordAndLogoutLifecycle(t *testing.T) {
router, db, _ := profileHandlerFixture(t)
rec := profileRequest(router, "POST", "/api/auth/login", `{"username":"profile-user","password":"old-password"}`, "")
if rec.Code != 200 {
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
}
var cookie string
for _, c := range rec.Result().Cookies() {
if c.Name == service.SessionCookieName {
cookie = c.Value
}
}
if cookie == "" {
t.Fatal("missing session")
}
var logs []map[string]any
if err := db.Table("login_logs").Order("created_at").Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0]["event"] != "login" || logs[0]["result"] != "success" || logs[0]["method"] != "password" {
t.Fatalf("login logs: %#v", logs)
}
for i := 0; i < 2; i++ {
rec = profileRequest(router, "POST", "/api/auth/logout", "", cookie)
if rec.Code != 200 {
t.Fatalf("logout: %d %s", rec.Code, rec.Body.String())
}
}
logs = nil
if err := db.Table("login_logs").Order("created_at").Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 2 || logs[1]["event"] != "logout" || logs[0]["session_ref"] != logs[1]["session_ref"] {
t.Fatalf("logout logs: %#v", logs)
}
}
func TestLoginLogsForgedAndExpiredLogoutDoNotRevokeOrLog(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
for _, value := range []string{"profile-session.forged-token", "", "invalid"} {
rec := profileRequest(router, "POST", "/api/auth/logout", "", value)
if rec.Code != 200 {
t.Fatalf("invalid logout: %d", rec.Code)
}
}
var count int64
db.Model(&model.AuthSession{}).Where("id = ?", "profile-session").Count(&count)
if count != 1 {
t.Fatal("forged token revoked a real session")
}
db.Model(&model.LoginLog{}).Count(&count)
if count != 0 {
t.Fatal("invalid logout generated success logs")
}
db.Model(&model.AuthSession{}).Where("id = ?", "profile-session").Update("expires_at", time.Now().Add(-time.Hour))
profileRequest(router, "POST", "/api/auth/logout", "", cookie)
db.Model(&model.LoginLog{}).Count(&count)
if count != 0 {
t.Fatal("expired session logged as active logout")
}
}
func TestLoginLogsWriteFailureRollsBackSessionChanges(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
if err := db.Exec(`CREATE TRIGGER reject_success_login_log BEFORE INSERT ON login_logs WHEN NEW.result = 'success' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`).Error; err != nil {
t.Fatal(err)
}
for _, req := range []struct{ path, body, cookie string }{
{"/api/auth/login", `{"username":"profile-user","password":"old-password"}`, ""},
{"/api/auth/logout", "", cookie},
} {
rec := profileRequest(router, "POST", req.path, req.body, req.cookie)
if rec.Code != 500 {
t.Fatalf("write failure %s: %d %s", req.path, rec.Code, rec.Body.String())
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("failed auth changed session cookie")
}
}
var count int64
if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil || count != 2 {
t.Fatalf("session transaction: %d %v", count, err)
}
if err := db.Model(&model.LoginLog{}).Where("result = ?", "success").Count(&count).Error; err != nil || count != 0 {
t.Fatal("failed operation logged as success")
}
}
func TestLoginLogsRejectedJSONAndSessionRefresh(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
bad := profileRequest(router, "POST", "/api/auth/login", "{broken-json", "")
if bad.Code != 400 {
t.Fatalf("bad JSON: %d", bad.Code)
}
for i := 0; i < 2; i++ {
profileRequest(router, "GET", "/api/auth/session", "", cookie)
}
var events []model.LoginLog
if err := db.Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Result != "failed" || events[0].Method != "password" || events[0].Account != "" {
t.Fatalf("rejection/refresh: %#v", events)
}
}
func TestLoginLogsAdminAccessAndFailedLogin(t *testing.T) {
router, db, cookie := profileHandlerFixture(t)
svc := service.New(repository.New(db), t.TempDir())
t.Cleanup(func() { _ = svc.Close() })
RegisterAdminRoutes(router.Group("/api"), svc)
for _, path := range []string{"/api/admin/login-logs", "/api/admin/login-logs/missing"} {
if rec := profileRequest(router, "GET", path, "", ""); rec.Code != 401 {
t.Fatalf("anonymous %s: %d", path, rec.Code)
}
if rec := profileRequest(router, "GET", path, "", cookie); rec.Code != 403 {
t.Fatalf("member %s: %d", path, rec.Code)
}
}
rec := profileRequest(router, "POST", "/api/auth/login", `{"username":"unknown@example.com","password":"never-store-this"}`, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("failed login: %d", rec.Code)
}
if err := db.Model(&model.User{}).Where("id = ?", "profile-user").Update("role", model.UserRoleAdmin).Error; err != nil {
t.Fatal(err)
}
rec = profileRequest(router, "GET", "/api/admin/login-logs?result=failed&method=password", "", cookie)
if rec.Code != 200 {
t.Fatalf("admin: %d %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "never-store-this") || strings.Contains(rec.Body.String(), "unknown@example.com") {
t.Fatal("sensitive login input exposed")
}
var body struct {
Data struct {
Logs []struct {
ID string `json:"id"`
UserID *string `json:"userId"`
}
Total int
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data.Total != 1 || len(body.Data.Logs) != 1 || body.Data.Logs[0].UserID != nil {
t.Fatalf("failed page: %s", rec.Body.String())
}
detail := profileRequest(router, "GET", "/api/admin/login-logs/"+body.Data.Logs[0].ID, "", cookie)
if detail.Code != 200 {
t.Fatalf("detail: %d", detail.Code)
}
if bad := profileRequest(router, "GET", "/api/admin/login-logs?event=invalid", "", cookie); bad.Code != 400 {
t.Fatalf("invalid filter: %d", bad.Code)
}
for _, query := range []string{"method=invalid", "result=invalid", "ip=invalid", "from=invalid", "from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z", "page=-1"} {
if bad := profileRequest(router, "GET", "/api/admin/login-logs?"+query, "", cookie); bad.Code != 400 {
t.Fatalf("invalid query %s: %d", query, bad.Code)
}
}
if missing := profileRequest(router, "GET", "/api/admin/login-logs/missing", "", cookie); missing.Code != 404 {
t.Fatalf("missing detail: %d", missing.Code)
}
}

View File

@@ -39,7 +39,7 @@ func registerNotificationRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, result)
})
r.POST("/auth/verification/login", func(c *gin.Context) {
r.POST("/auth/verification/login", rejectedLoginAudit(svc, "verification"), func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var req service.VerificationConfirm
if err := c.ShouldBindJSON(&req); err != nil {
@@ -49,6 +49,8 @@ func registerNotificationRoutes(r *gin.RouterGroup, svc *service.Service) {
if !enforceRateLimit(c, "verification-login:"+c.ClientIP(), 30, time.Hour) {
return
}
req.LoginContext = loginRequestContext(c)
c.Set("login_audit_handled", true)
result, err := svc.LoginVerification(req)
if err != nil {
failService(c, err)

View File

@@ -27,6 +27,66 @@ tags:
- name: admin
- name: skills
paths:
/admin/login-logs:
get:
tags: [admin]
summary: 管理员分页查询用户登录登出日志
description: 按时间倒序,保留用户快照;失败账号脱敏,无密码、验证码或会话凭据。
parameters:
- {in: query, name: keyword, schema: {type: string, maxLength: 160}, description: 用户名、昵称、脱敏账号或用户 ID}
- {in: query, name: event, schema: {type: string, enum: [login, logout]}}
- {in: query, name: method, schema: {type: string, enum: [password, email, sms, linuxdo, verification, unknown]}}
- {in: query, name: result, schema: {type: string, enum: [success, failed]}}
- {in: query, name: ip, schema: {type: string}, description: 精确匹配 IPv4 或 IPv6}
- {in: query, name: from, schema: {type: string, format: date-time}, description: 包含开始时刻,RFC3339}
- {in: query, name: to, schema: {type: string, format: date-time}, description: 包含结束时刻,RFC3339}
- {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}}
- {in: query, name: pageSize, schema: {type: integer, minimum: 1, default: 20}}
responses:
"200":
description: 成功信封 data={logs,total,page,pageSize}
content:
application/json:
schema:
type: object
properties:
code: {type: integer, enum: [0]}
msg: {type: string}
data:
type: object
properties:
logs: {type: array, items: {$ref: "#/components/schemas/LoginLog"}}
total: {type: integer}
page: {type: integer}
pageSize: {type: integer}
"400": {$ref: "#/components/responses/Error"}
"401": {$ref: "#/components/responses/Error"}
"403": {$ref: "#/components/responses/Error"}
"500": {$ref: "#/components/responses/Error"}
/admin/login-logs/{id}:
get:
tags: [admin]
summary: 管理员读取单条登录日志详情
parameters:
- {in: path, name: id, required: true, schema: {type: string}}
responses:
"200":
description: 成功信封 data.log
content:
application/json:
schema:
type: object
properties:
code: {type: integer, enum: [0]}
msg: {type: string}
data:
type: object
properties:
log: {$ref: "#/components/schemas/LoginLog"}
"401": {$ref: "#/components/responses/Error"}
"403": {$ref: "#/components/responses/Error"}
"404": {$ref: "#/components/responses/Error"}
"500": {$ref: "#/components/responses/Error"}
/admin/settings/appearance/live2d:
post:
tags: [admin]
@@ -1056,6 +1116,202 @@ paths:
$ref: "#/components/responses/Error"
"404":
$ref: "#/components/responses/Error"
/auth/profile:
get:
tags: [auth]
summary: 当前用户个人信息及安全能力
description: 返回 user、hasPassword、emailEnabled、linuxdoEnabled 和可选 reauthenticatedUntil;个人头像选择覆盖第三方头像。内部密码散列与头像版本字段不返回。
responses:
"200":
$ref: "#/components/responses/Envelope"
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
/auth/profile/avatar:
put:
tags: [auth]
summary: 保存当前用户头像
description: 真实 PNG/JPEG,最大 5 MiB、1600 万像素,居中裁剪并缩小到最大512像素PNG。响应 data.user 包含更新后的头像地址。
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
delete:
tags: [auth]
summary: 使用系统默认头像
description: 清除本用户头像并保存 default 选择,后续第三方登录仍使用系统头像。返回 data.user。
responses:
"200":
$ref: "#/components/responses/Envelope"
"401":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
/auth/profile/avatar/default:
post:
tags: [auth]
summary: 选择系统头像
description: 保存选择并原子清除自定义图片,返回 data.user。系统头像由共享组件渲染;不接受客户端 URL 或任意图标标识。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [preset]
properties:
preset:
type: string
enum: [person, director, camera, cat, rabbit, robot, rocket, sparkles]
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
/auth/profile/avatar/{userId}:
get:
tags: [auth]
summary: 读取当前账号头像 PNG
parameters:
- name: userId
in: path
required: true
schema: { type: string }
- name: v
in: query
schema: { type: string }
responses:
"200":
description: 仅当前用户可读,private no-store
content:
image/png:
schema: { type: string, format: binary }
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"404":
$ref: "#/components/responses/Error"
/auth/profile/password:
post:
tags: [auth]
summary: 修改或设置本地密码并撤销所有会话
description: 已有密码必须校验 currentPassword;纯第三方账号须在当前会话完成5分钟内的Linux.do重新授权核验。成功返回 data.changed=true 并清除会话 Cookie。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [newPassword]
properties:
currentPassword: { type: string, format: password }
newPassword:
type: string
format: password
minLength: 8
maxLength: 72
description: 至少8个Unicode字符,UTF-8字节长度不超过72
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
/auth/profile/email-code:
post:
tags: [auth]
summary: 向新邮箱发送专用验证码
description: 验证当前密码或当前会话最近的Linux.do核验;新地址未被占用且不同于旧地址。返回 ticket、expiresIn=600、retryAfter=60;票据绑定用户/会话/凭据,仅profile_email用途有效。发送失败显式报错。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
currentPassword: { type: string, format: password }
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
"503":
$ref: "#/components/responses/Error"
/auth/profile/email:
post:
tags: [auth]
summary: 验证新邮箱并更新绑定
description: 原子校验邮箱唯一性、消费验证码、更新已验证邮箱并撤销全部会话;清除Cookie。返回changed=true及notificationStatus(sent/failed/not_needed),failed仅表示已更新后的原邮箱通知失败。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ticket, emailCode]
properties:
ticket: { type: string }
emailCode: { type: string, pattern: "^[0-9]{6}$" }
currentPassword: { type: string, format: password }
responses:
"200":
$ref: "#/components/responses/Envelope"
"400":
$ref: "#/components/responses/Error"
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
/auth/profile/linuxdo/start:
get:
tags: [auth]
summary: 发起当前账号Linux.do身份重新核验
description: OAuth state绑定当前账号和当前会话,回调只接受原绑定subject;不创建账号或新登录会话。授权成功返回/settings?section=profile&reauth=success,失败返回reauth=error。
responses:
"302":
description: 跳转到第三方授权页面
headers:
Location:
schema: { type: string, format: uri }
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"429":
$ref: "#/components/responses/Error"
/auth/session:
get:
tags: [auth]
@@ -1300,6 +1556,24 @@ components:
in: cookie
name: open_ai_canvas_session
schemas:
LoginLog:
type: object
properties:
id: {type: string}
userId: {type: string, nullable: true}
username: {type: string, description: 事件时用户名快照}
displayName: {type: string}
account: {type: string, description: 用户名或脱敏的尝试账号}
event: {type: string, enum: [login, logout]}
method: {type: string, enum: [password, email, sms, linuxdo, verification, unknown]}
source: {type: string, enum: [login, registration, logout]}
result: {type: string, enum: [success, failed]}
ip: {type: string}
userAgent: {type: string, maxLength: 512, description: 客户端自报信息,不作为身份判断依据}
sessionRef: {type: string, description: 会话 ID 的单向哈希,仅关联历史事件,不含 Token}
reason: {type: string}
message: {type: string}
createdAt: {type: string, format: date-time}
ToolMutation:
type: object
required: [type, label, prompt]