mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 23:06:36 +08:00
refactor(auth): modularize auth plugin with physical subpackages and decoupled services
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package do provides domain data objects for the auth plugin.
|
||||
package do
|
||||
|
||||
// CachedToken represents the minimal cached representation of an access token.
|
||||
type CachedToken struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package do provides domain data objects for the auth plugin.
|
||||
package do
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/consts"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CapRuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs.
|
||||
type CapRuntimeSettings struct {
|
||||
LoginEnabled bool
|
||||
ChallengeCount int
|
||||
ChallengeSize int
|
||||
ChallengeDifficulty int
|
||||
ChallengeTTL time.Duration
|
||||
TokenTTL time.Duration
|
||||
}
|
||||
|
||||
// CapConfigRecord maps the columns selected from the system config table.
|
||||
type CapConfigRecord struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
|
||||
// ParseCapRuntimeSettings parses system config key-value map into CapRuntimeSettings with fallback defaults.
|
||||
func ParseCapRuntimeSettings(configs map[string]string) CapRuntimeSettings {
|
||||
settings := CapRuntimeSettings{
|
||||
ChallengeCount: consts.DefaultCapChallengeCount,
|
||||
ChallengeSize: consts.DefaultCapChallengeSize,
|
||||
ChallengeDifficulty: consts.DefaultCapChallengeDifficulty,
|
||||
ChallengeTTL: consts.DefaultCapChallengeTTL,
|
||||
TokenTTL: consts.DefaultCapTokenTTL,
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
if val, ok := configs[consts.ConfigKeyCapLoginEnabled]; ok {
|
||||
if enabled, err := strconv.ParseBool(val); err == nil {
|
||||
settings.LoginEnabled = enabled
|
||||
}
|
||||
}
|
||||
if val, ok := configs[consts.ConfigKeyCapChallengeCount]; ok {
|
||||
if count, err := strconv.Atoi(val); err == nil && count > 0 {
|
||||
settings.ChallengeCount = count
|
||||
}
|
||||
}
|
||||
if val, ok := configs[consts.ConfigKeyCapChallengeSize]; ok {
|
||||
if size, err := strconv.Atoi(val); err == nil && size > 0 {
|
||||
settings.ChallengeSize = size
|
||||
}
|
||||
}
|
||||
if val, ok := configs[consts.ConfigKeyCapChallengeDifficulty]; ok {
|
||||
if diff, err := strconv.Atoi(val); err == nil && diff > 0 {
|
||||
settings.ChallengeDifficulty = diff
|
||||
}
|
||||
}
|
||||
if val, ok := configs[consts.ConfigKeyCapChallengeTTL]; ok {
|
||||
if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 {
|
||||
settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
if val, ok := configs[consts.ConfigKeyCapTokenTTL]; ok {
|
||||
if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 {
|
||||
settings.TokenTTL = time.Duration(ttlSeconds) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package do_test
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/consts"
|
||||
"Wavelet/plugins/domain/auth/model/do"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseCapRuntimeSettings(t *testing.T) {
|
||||
t.Run("Default fallback on empty config", func(t *testing.T) {
|
||||
settings := do.ParseCapRuntimeSettings(nil)
|
||||
assert.False(t, settings.LoginEnabled)
|
||||
assert.Equal(t, consts.DefaultCapChallengeCount, settings.ChallengeCount)
|
||||
assert.Equal(t, consts.DefaultCapChallengeSize, settings.ChallengeSize)
|
||||
assert.Equal(t, consts.DefaultCapChallengeDifficulty, settings.ChallengeDifficulty)
|
||||
assert.Equal(t, consts.DefaultCapChallengeTTL, settings.ChallengeTTL)
|
||||
assert.Equal(t, consts.DefaultCapTokenTTL, settings.TokenTTL)
|
||||
})
|
||||
|
||||
t.Run("Parsed custom configs", func(t *testing.T) {
|
||||
configs := map[string]string{
|
||||
consts.ConfigKeyCapLoginEnabled: "true",
|
||||
consts.ConfigKeyCapChallengeCount: "3",
|
||||
consts.ConfigKeyCapChallengeSize: "64",
|
||||
consts.ConfigKeyCapChallengeDifficulty: "5",
|
||||
consts.ConfigKeyCapChallengeTTL: "300",
|
||||
consts.ConfigKeyCapTokenTTL: "600",
|
||||
}
|
||||
settings := do.ParseCapRuntimeSettings(configs)
|
||||
assert.True(t, settings.LoginEnabled)
|
||||
assert.Equal(t, 3, settings.ChallengeCount)
|
||||
assert.Equal(t, 64, settings.ChallengeSize)
|
||||
assert.Equal(t, 5, settings.ChallengeDifficulty)
|
||||
assert.Equal(t, 300*time.Second, settings.ChallengeTTL)
|
||||
assert.Equal(t, 600*time.Second, settings.TokenTTL)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package do provides domain data objects for the auth plugin.
|
||||
package do
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// OAuthStatePayload represents the cached state verification payload for OAuth flow.
|
||||
type OAuthStatePayload struct {
|
||||
SourceName string `json:"source_name"`
|
||||
Purpose string `json:"purpose"`
|
||||
UserID uint64 `json:"user_id,omitempty"`
|
||||
SessionHash string `json:"session_hash"`
|
||||
}
|
||||
|
||||
// Encode converts OAuthStatePayload to a JSON string.
|
||||
func (p OAuthStatePayload) Encode() (string, error) {
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DecodeOAuthStatePayload parses a JSON string into OAuthStatePayload.
|
||||
func DecodeOAuthStatePayload(value string) (OAuthStatePayload, error) {
|
||||
var payload OAuthStatePayload
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
return OAuthStatePayload{}, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package do_test
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/model/do"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOAuthStatePayload(t *testing.T) {
|
||||
payload := do.OAuthStatePayload{
|
||||
SourceName: "github",
|
||||
Purpose: "login",
|
||||
UserID: 12345,
|
||||
SessionHash: "hash-abc-123",
|
||||
}
|
||||
|
||||
encoded, err := payload.Encode()
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, encoded)
|
||||
|
||||
decoded, err := do.DecodeOAuthStatePayload(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, decoded)
|
||||
|
||||
_, err = do.DecodeOAuthStatePayload("invalid-json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package dto provides data transfer objects and views for the auth plugin.
|
||||
package dto
|
||||
|
||||
// AuthSourceView 登录源展示信息
|
||||
type AuthSourceView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IconURL string `json:"icon_url"`
|
||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||
}
|
||||
|
||||
// OAuthAuthorizeResponse 授权 URL 响应
|
||||
type OAuthAuthorizeResponse struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
}
|
||||
|
||||
// OAuthCallbackResult 回调处理结果
|
||||
type OAuthCallbackResult struct {
|
||||
Status string `json:"status"`
|
||||
User *BasicUserInfo `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// CallbackRequest OAuth 回调请求参数
|
||||
type CallbackRequest struct {
|
||||
State string `json:"state" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// ExternalAccountView 外部帐号绑定视图(脱敏展示用)
|
||||
type ExternalAccountView struct {
|
||||
ID uint64 `json:"id"`
|
||||
AuthSourceID uint64 `json:"auth_source_id"`
|
||||
AuthSourceName string `json:"auth_source_name"`
|
||||
AuthSourceType string `json:"auth_source_type"`
|
||||
AuthSourceLabel string `json:"auth_source_label"`
|
||||
ExternalUsername string `json:"external_username"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package dto provides data transfer objects and views for the auth plugin.
|
||||
package dto
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/pow"
|
||||
)
|
||||
|
||||
// ChallengeResponse is a local type alias for the pow.ChallengeResponse struct
|
||||
type ChallengeResponse = pow.ChallengeResponse
|
||||
|
||||
// ChallengeRequest is the CAPTCHA challenge request payload.
|
||||
type ChallengeRequest struct {
|
||||
Scope string `json:"scope" form:"scope"`
|
||||
}
|
||||
|
||||
// RedeemRequest is the CAPTCHA redeem request payload.
|
||||
type RedeemRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Solutions []int `json:"solutions" binding:"required"`
|
||||
Scope string `json:"scope" form:"scope"`
|
||||
}
|
||||
|
||||
// RedeemResponse is returned to the client on redeem.
|
||||
type RedeemResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package dto provides data transfer objects and views for the auth plugin.
|
||||
package dto
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// BasicUserInfo 用户基本信息结构体
|
||||
type BasicUserInfo struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
NeedChangePassword bool `json:"need_change_password"`
|
||||
Bio string `json:"bio"`
|
||||
Phone string `json:"phone"`
|
||||
Gender string `json:"gender"`
|
||||
Website string `json:"website"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
// BuildBasicUserInfo 将 UserDTO 转换为 BasicUserInfo
|
||||
func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo {
|
||||
if user == nil {
|
||||
return BasicUserInfo{}
|
||||
}
|
||||
return BasicUserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
AvatarURL: user.AvatarURL,
|
||||
IsAdmin: user.IsAdmin,
|
||||
NeedChangePassword: needChange || user.NeedChangePassword,
|
||||
Bio: user.Bio,
|
||||
Phone: user.Phone,
|
||||
Gender: user.Gender,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRequiredAuditLog 审计日志结构体
|
||||
type LoginRequiredAuditLog struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
RequestURI string `json:"request_uri"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Referer string `json:"referer"`
|
||||
}
|
||||
|
||||
// ParseUserID parses a string, int, or float64 user ID representation.
|
||||
func ParseUserID(v any) uint64 {
|
||||
switch val := v.(type) {
|
||||
case uint64:
|
||||
return val
|
||||
case int64:
|
||||
if val > 0 {
|
||||
return uint64(val)
|
||||
}
|
||||
case int:
|
||||
if val > 0 {
|
||||
return uint64(val)
|
||||
}
|
||||
case float64:
|
||||
if val > 0 {
|
||||
return uint64(val)
|
||||
}
|
||||
case string:
|
||||
if id, err := strconv.ParseUint(val, 10, 64); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package entity provides database model entities for the auth domain plugin.
|
||||
package entity
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/consts"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
|
||||
|
||||
// AuthSource 认证源实体
|
||||
type AuthSource struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
|
||||
Type string `json:"type" gorm:"size:20;not null"`
|
||||
DisplayName string `json:"display_name" gorm:"size:100"`
|
||||
IsActive bool `json:"is_active" gorm:"index;not null;default:false"`
|
||||
ClientID string `json:"client_id" gorm:"size:255"`
|
||||
ClientSecret string `json:"-" gorm:"size:1024"`
|
||||
OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"`
|
||||
Scopes string `json:"scopes" gorm:"size:255"`
|
||||
IconURL string `json:"icon_url" gorm:"size:1024"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (AuthSource) TableName() string {
|
||||
return "w_auth_sources"
|
||||
}
|
||||
|
||||
// Normalize 对认证源字段进行标准化处理
|
||||
func (source *AuthSource) Normalize() {
|
||||
source.Type = strings.ToLower(strings.TrimSpace(source.Type))
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
source.DisplayName = strings.TrimSpace(source.DisplayName)
|
||||
source.ClientID = strings.TrimSpace(source.ClientID)
|
||||
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
|
||||
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
|
||||
source.Scopes = strings.TrimSpace(source.Scopes)
|
||||
source.IconURL = strings.TrimSpace(source.IconURL)
|
||||
if source.DisplayName == "" {
|
||||
source.DisplayName = source.Name
|
||||
}
|
||||
if source.Type == consts.AuthSourceTypeOIDC && source.Scopes == "" {
|
||||
source.Scopes = "openid profile email"
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验认证源字段合法性
|
||||
func (source *AuthSource) Validate() error {
|
||||
source.Normalize()
|
||||
if source.Name == "" {
|
||||
return errors.New(consts.ErrAuthSourceNameRequired)
|
||||
}
|
||||
if !authSourceNamePattern.MatchString(source.Name) {
|
||||
return errors.New(consts.ErrAuthSourceNameInvalid)
|
||||
}
|
||||
if source.Type != consts.AuthSourceTypeOIDC {
|
||||
return errors.New(consts.ErrAuthSourceTypeUnsupported)
|
||||
}
|
||||
if source.OpenIDDiscoveryURL == "" {
|
||||
//nolint:staticcheck // descriptive error constant
|
||||
return errors.New(consts.ErrAuthSourceDiscoveryURLRequired)
|
||||
}
|
||||
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
|
||||
return errors.New(consts.ErrAuthSourceClientCredentialsRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志
|
||||
func (source *AuthSource) Sanitize() {
|
||||
source.ClientSecretConfigured = source.ClientSecret != ""
|
||||
source.ClientSecret = ""
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package entity_test
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth/model/entity"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthSourceValidation(t *testing.T) {
|
||||
t.Run("Valid OIDC Source", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "google",
|
||||
Type: "oidc",
|
||||
DisplayName: "Google Sign-In",
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret-456",
|
||||
OpenIDDiscoveryURL: "https://accounts.google.com",
|
||||
IsActive: true,
|
||||
}
|
||||
require.NoError(t, src.Validate())
|
||||
assert.Equal(t, "openid profile email", src.Scopes)
|
||||
assert.Equal(t, "w_auth_sources", src.TableName())
|
||||
|
||||
src.Sanitize()
|
||||
assert.True(t, src.ClientSecretConfigured)
|
||||
assert.Empty(t, src.ClientSecret)
|
||||
})
|
||||
|
||||
t.Run("Empty Name Fails", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "",
|
||||
Type: "oidc",
|
||||
}
|
||||
assert.Error(t, src.Validate())
|
||||
})
|
||||
|
||||
t.Run("Invalid Name Format Fails", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "invalid name with spaces!",
|
||||
Type: "oidc",
|
||||
}
|
||||
assert.Error(t, src.Validate())
|
||||
})
|
||||
|
||||
t.Run("Unsupported Type Fails", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "ldap_source",
|
||||
Type: "ldap",
|
||||
}
|
||||
assert.Error(t, src.Validate())
|
||||
})
|
||||
|
||||
t.Run("Missing Discovery URL Fails", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "google",
|
||||
Type: "oidc",
|
||||
}
|
||||
assert.Error(t, src.Validate())
|
||||
})
|
||||
|
||||
t.Run("Active Source Missing Credentials Fails", func(t *testing.T) {
|
||||
src := entity.AuthSource{
|
||||
Name: "google",
|
||||
Type: "oidc",
|
||||
OpenIDDiscoveryURL: "https://accounts.google.com",
|
||||
IsActive: true,
|
||||
}
|
||||
assert.Error(t, src.Validate())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package entity provides database model entities for the auth domain plugin.
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExternalAccount 外部账号绑定实体
|
||||
type ExternalAccount struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey"`
|
||||
AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"`
|
||||
UserID uint64 `json:"user_id" gorm:"index;not null"`
|
||||
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"`
|
||||
ExternalUsername string `json:"external_username" gorm:"size:255"`
|
||||
Email string `json:"email" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (ExternalAccount) TableName() string {
|
||||
return "w_external_accounts"
|
||||
}
|
||||
Reference in New Issue
Block a user