feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
64
backend/internal/handler/admin_login_log_routes.go
Normal file
64
backend/internal/handler/admin_login_log_routes.go
Normal 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})
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
165
backend/internal/handler/auth_profile.go
Normal file
165
backend/internal/handler/auth_profile.go
Normal 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)
|
||||
})
|
||||
}
|
||||
220
backend/internal/handler/auth_profile_test.go
Normal file
220
backend/internal/handler/auth_profile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
178
backend/internal/handler/login_logs_test.go
Normal file
178
backend/internal/handler/login_logs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user