feat(admin): 添加用户管理和Linux.do登录功能
- 新增AdminUser类型定义及用户管理API接口 - 实现用户列表查询、保存和删除功能 - 添加Linux.do第三方登录支持 - 集成Linux.do OAuth认证流程 - 在系统设置中添加Linux.do登录配置选项 - 实现算力点余额调整记录功能 - 优化搜索组件关键词状态管理 - 更新数据库迁移添加credit_logs表 - 完善用户状态和信用等级验证逻辑
This commit is contained in:
+248
-9
@@ -1,8 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,6 +46,8 @@ func EnsureDefaultAdmin() error {
|
||||
Username: strings.TrimSpace(config.Cfg.AdminUsername),
|
||||
Password: hash,
|
||||
Role: model.UserRoleAdmin,
|
||||
AffCode: newAffCode(),
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
@@ -46,19 +55,18 @@ func EnsureDefaultAdmin() error {
|
||||
}
|
||||
|
||||
func Register(username string, password string) (model.AuthSession, error) {
|
||||
return model.AuthSession{}, errors.New("注册功能暂时关闭")
|
||||
username = strings.TrimSpace(username)
|
||||
if strings.ContainsAny(username, " \t\r\n") {
|
||||
return model.AuthSession{}, errors.New("用户名不能包含空格")
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return model.AuthSession{}, errors.New("用户名和密码不能为空")
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
|
||||
}
|
||||
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return model.AuthSession{}, errors.New("用户名已存在")
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
@@ -69,6 +77,8 @@ func Register(username string, password string) (model.AuthSession, error) {
|
||||
Username: username,
|
||||
Password: hash,
|
||||
Role: model.UserRoleUser,
|
||||
AffCode: newAffCode(),
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
@@ -84,11 +94,102 @@ func Login(username string, password string) (model.AuthSession, error) {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return model.AuthSession{}, errors.New("用户名或密码错误")
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"}
|
||||
}
|
||||
if user.Status == model.UserStatusBan {
|
||||
return model.AuthSession{}, safeMessageError{message: "账号已被禁用"}
|
||||
}
|
||||
normalizeUserDefaults(&user)
|
||||
user.LastLoginAt = now()
|
||||
user.UpdatedAt = now()
|
||||
user, err = repository.SaveUser(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return newSession(user)
|
||||
}
|
||||
|
||||
func LinuxDoAuthorizeURL(redirect string) (string, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
linuxDo := settings.Private.Auth.LinuxDo
|
||||
if !settings.Public.Auth.LinuxDo.Enabled {
|
||||
return "", safeMessageError{message: "Linux.do 登录未开启"}
|
||||
}
|
||||
if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" || strings.TrimSpace(linuxDo.RedirectURI) == "" {
|
||||
return "", safeMessageError{message: "Linux.do 登录未配置"}
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("client_id", linuxDo.ClientID)
|
||||
values.Set("redirect_uri", linuxDo.RedirectURI)
|
||||
values.Set("response_type", "code")
|
||||
values.Set("scope", "read")
|
||||
values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect)))
|
||||
return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil
|
||||
}
|
||||
|
||||
func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, error) {
|
||||
redirect := decodeState(state)
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
linuxDo := settings.Private.Auth.LinuxDo
|
||||
if !settings.Public.Auth.LinuxDo.Enabled {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"}
|
||||
}
|
||||
token, err := linuxDoAccessToken(code, linuxDo)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
profile, err := linuxDoProfile(token)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
linuxDoID := fmt.Sprint(profile.ID)
|
||||
if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"}
|
||||
}
|
||||
if profile.TrustLevel < linuxDo.MinimumTrustLevel {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 账号信任等级不足"}
|
||||
}
|
||||
user, ok, err := repository.GetUserByLinuxDoID(linuxDoID)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
if !ok {
|
||||
user = model.User{
|
||||
ID: newID("user"),
|
||||
Username: linuxDoUsername(profile.Username, linuxDoID),
|
||||
DisplayName: strings.TrimSpace(profile.Name),
|
||||
AvatarURL: linuxDoAvatar(profile.AvatarTemplate),
|
||||
Role: model.UserRoleUser,
|
||||
AffCode: newAffCode(),
|
||||
LinuxDoID: linuxDoID,
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
}
|
||||
} else if user.Status == model.UserStatusBan {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"}
|
||||
}
|
||||
user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName)
|
||||
user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
|
||||
user.LastLoginAt = now()
|
||||
user.UpdatedAt = now()
|
||||
extra, _ := json.Marshal(profile)
|
||||
user.Extra = string(extra)
|
||||
user, err = repository.SaveUser(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
session, err := newSession(user)
|
||||
return session, redirect, err
|
||||
}
|
||||
|
||||
func ParseToken(tokenText string) (TokenClaims, error) {
|
||||
claims := TokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
|
||||
@@ -122,6 +223,7 @@ func ListUsers(q model.Query) (model.UserList, error) {
|
||||
}
|
||||
for i := range users {
|
||||
users[i].Password = ""
|
||||
normalizeUserDefaults(&users[i])
|
||||
}
|
||||
return model.UserList{Items: users, Total: int(total)}, nil
|
||||
}
|
||||
@@ -129,27 +231,43 @@ func ListUsers(q model.Query) (model.UserList, error) {
|
||||
func SaveUser(user model.User, password string) (model.User, error) {
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
if strings.ContainsAny(user.Username, " \t\r\n") {
|
||||
return user, errors.New("用户名不能包含空格")
|
||||
return user, safeMessageError{message: "用户名不能包含空格"}
|
||||
}
|
||||
if user.Username == "" {
|
||||
return user, errors.New("用户名不能为空")
|
||||
return user, safeMessageError{message: "用户名不能为空"}
|
||||
}
|
||||
if user.Role == "" || user.Role == model.UserRoleGuest {
|
||||
user.Role = model.UserRoleUser
|
||||
}
|
||||
if user.Status == "" {
|
||||
user.Status = model.UserStatusActive
|
||||
}
|
||||
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
|
||||
return user, err
|
||||
} else if ok && saved.ID != user.ID {
|
||||
return user, errors.New("用户名已存在")
|
||||
return user, safeMessageError{message: "用户名已存在"}
|
||||
}
|
||||
oldCredits := 0
|
||||
if user.ID == "" {
|
||||
user.ID = newID("user")
|
||||
user.AffCode = newAffCode()
|
||||
user.CreatedAt = now()
|
||||
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
||||
return user, err
|
||||
} else if ok {
|
||||
oldCredits = saved.Credits
|
||||
user.CreatedAt = saved.CreatedAt
|
||||
user.Password = saved.Password
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = saved.AffCode
|
||||
}
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = newAffCode()
|
||||
}
|
||||
if user.LinuxDoID == "" {
|
||||
user.LinuxDoID = saved.LinuxDoID
|
||||
}
|
||||
user.LastLoginAt = saved.LastLoginAt
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := hashPassword(password)
|
||||
@@ -159,10 +277,21 @@ func SaveUser(user model.User, password string) (model.User, error) {
|
||||
user.Password = hash
|
||||
}
|
||||
if user.Password == "" {
|
||||
return user, errors.New("密码不能为空")
|
||||
return user, safeMessageError{message: "密码不能为空"}
|
||||
}
|
||||
user.UpdatedAt = now()
|
||||
user, err := repository.SaveUser(user)
|
||||
if err == nil && user.Credits != oldCredits {
|
||||
_, err = repository.SaveCreditLog(model.CreditLog{
|
||||
ID: newID("credit"),
|
||||
UserID: user.ID,
|
||||
Type: model.CreditLogTypeAdminAdjust,
|
||||
Amount: user.Credits - oldCredits,
|
||||
Balance: user.Credits,
|
||||
Remark: "后台手动调整",
|
||||
CreatedAt: now(),
|
||||
})
|
||||
}
|
||||
user.Password = ""
|
||||
return user, err
|
||||
}
|
||||
@@ -214,6 +343,116 @@ func newID(prefix string) string {
|
||||
return prefix + "-" + uuid.NewString()
|
||||
}
|
||||
|
||||
func newAffCode() string {
|
||||
return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", ""))
|
||||
}
|
||||
|
||||
func normalizeUserDefaults(user *model.User) {
|
||||
if user.Status == "" {
|
||||
user.Status = model.UserStatusActive
|
||||
}
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = newAffCode()
|
||||
}
|
||||
}
|
||||
|
||||
type linuxDoTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
type linuxDoUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
AvatarTemplate string `json:"avatar_template"`
|
||||
TrustLevel int `json:"trust_level"`
|
||||
}
|
||||
|
||||
func linuxDoAccessToken(code string, setting model.PrivateLinuxDoAuthSetting) (string, error) {
|
||||
values := url.Values{}
|
||||
values.Set("client_id", setting.ClientID)
|
||||
values.Set("client_secret", setting.ClientSecret)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("code", code)
|
||||
values.Set("redirect_uri", setting.RedirectURI)
|
||||
req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
var payload linuxDoTokenResponse
|
||||
if err := doLinuxDoJSON(req, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return "", safeMessageError{message: "Linux.do 登录失败"}
|
||||
}
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
|
||||
func linuxDoProfile(token string) (linuxDoUserResponse, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
var payload linuxDoUserResponse
|
||||
err := doLinuxDoJSON(req, &payload)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func doLinuxDoJSON(req *http.Request, payload any) error {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return safeMessageError{message: "Linux.do 登录失败"}
|
||||
}
|
||||
return json.NewDecoder(bytes.NewReader(body)).Decode(payload)
|
||||
}
|
||||
|
||||
func linuxDoUsername(username string, id string) string {
|
||||
base := strings.TrimSpace(username)
|
||||
if base == "" {
|
||||
base = "linuxdo-" + id
|
||||
}
|
||||
if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok {
|
||||
return base
|
||||
}
|
||||
return base + "-" + id
|
||||
}
|
||||
|
||||
func linuxDoAvatar(template string) string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(template, "//") {
|
||||
template = "https:" + template
|
||||
}
|
||||
if strings.HasPrefix(template, "/") {
|
||||
template = "https://linux.do" + template
|
||||
}
|
||||
return strings.ReplaceAll(template, "{size}", "120")
|
||||
}
|
||||
|
||||
func decodeState(state string) string {
|
||||
data, err := base64.RawURLEncoding.DecodeString(state)
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
redirect := string(data)
|
||||
if !strings.HasPrefix(redirect, "/") {
|
||||
return "/"
|
||||
}
|
||||
return redirect
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func WarnDefaultSecurityConfig() {
|
||||
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
|
||||
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
|
||||
|
||||
@@ -31,6 +31,7 @@ func SaveSettings(settings model.Settings) (model.Settings, error) {
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
||||
keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
|
||||
result, err := repository.SaveSettings(settings, now())
|
||||
if err == nil {
|
||||
RefreshPromptSyncScheduler()
|
||||
@@ -76,6 +77,9 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting
|
||||
setting.Channels = []model.ModelChannel{}
|
||||
}
|
||||
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
|
||||
if setting.Auth.LinuxDo.MinimumTrustLevel < 0 {
|
||||
setting.Auth.LinuxDo.MinimumTrustLevel = 0
|
||||
}
|
||||
for i := range setting.Channels {
|
||||
if setting.Channels[i].Protocol == "" {
|
||||
setting.Channels[i].Protocol = "openai"
|
||||
@@ -94,6 +98,7 @@ func hidePrivateAPIKeys(settings model.Settings) model.Settings {
|
||||
for i := range settings.Private.Channels {
|
||||
settings.Private.Channels[i].APIKey = ""
|
||||
}
|
||||
settings.Private.Auth.LinuxDo.ClientSecret = ""
|
||||
return settings
|
||||
}
|
||||
|
||||
@@ -108,6 +113,12 @@ func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) {
|
||||
if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" {
|
||||
settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret
|
||||
}
|
||||
}
|
||||
|
||||
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
|
||||
for _, item := range saved {
|
||||
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
|
||||
|
||||
Reference in New Issue
Block a user