mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 06:56:36 +08:00
feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_auth_sources (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL UNIQUE,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
display_name VARCHAR(100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
client_id VARCHAR(255),
|
||||
client_secret VARCHAR(1024),
|
||||
openid_discovery_url VARCHAR(1024),
|
||||
scopes VARCHAR(255),
|
||||
icon_url VARCHAR(1024),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_external_accounts (
|
||||
id BIGINT PRIMARY KEY,
|
||||
auth_source_id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
external_username VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_external_accounts;
|
||||
DROP TABLE IF EXISTS w_auth_sources;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package auth provides the authentication, OAuth, session management, and access token domain plugin for Cordis.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var authMigrations embed.FS
|
||||
|
||||
// Option configures the auth plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// WithAuthService sets a custom AuthService implementation.
|
||||
func WithAuthService(svc contracts.AuthService) Option {
|
||||
return func(p *Plugin) {
|
||||
p.authSvc = svc
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthRegistry sets a custom AuthRegistry implementation.
|
||||
func WithAuthRegistry(reg contracts.AuthRegistry) Option {
|
||||
return func(p *Plugin) {
|
||||
p.authRegistry = reg
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin implements core.Plugin to provide authentication and OAuth domain services.
|
||||
type Plugin struct {
|
||||
authSvc contracts.AuthService
|
||||
authRegistry contracts.AuthRegistry
|
||||
}
|
||||
|
||||
// New creates a new auth domain plugin.
|
||||
func New(opts ...Option) *Plugin {
|
||||
p := &Plugin{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(p)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Name returns the unique identifier for the auth domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "auth"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "auth",
|
||||
Version: "1.0.0",
|
||||
Description: "Authentication, OAuth, Session and Passkey domain plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers the auth migrations, services, routes, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register migrations
|
||||
ctx.Migrations().Register("auth", authMigrations)
|
||||
|
||||
// 2. Initialize and provide AuthService & AuthRegistry
|
||||
if p.authSvc == nil {
|
||||
p.authSvc = newAuthService()
|
||||
}
|
||||
if p.authRegistry == nil {
|
||||
p.authRegistry = newAuthRegistry()
|
||||
}
|
||||
|
||||
core.Provide[contracts.AuthService](ctx, p.authSvc)
|
||||
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
|
||||
|
||||
// 3. Register HTTP Routes
|
||||
oauthGroup := ctx.Router().Group("/api/v1/oauth")
|
||||
{
|
||||
oauthGroup.GET("/sources", oauth.GetLoginSources)
|
||||
oauthGroup.GET("/login", oauth.GetLoginURL)
|
||||
oauthGroup.GET("/:source/authorize", oauth.Authorize)
|
||||
oauthGroup.GET("/logout", oauth.Logout)
|
||||
oauthGroup.POST("/callback", oauth.Callback)
|
||||
oauthGroup.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
|
||||
oauthGroup.GET("/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts)
|
||||
oauthGroup.POST("/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount)
|
||||
}
|
||||
ctx.Router().GET("/api/v1/user-info", oauth.LoginRequired(), oauth.UserInfo)
|
||||
|
||||
// 4. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "auth.session_age",
|
||||
Default: 86400 * 7,
|
||||
Description: "Default session lifetime in seconds",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "auth.login_rate_limit_max_attempts",
|
||||
Default: 5,
|
||||
Description: "Max login failure attempts before temporary IP lock",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "auth_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, testDB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AccessToken{},
|
||||
&model.AuthSource{},
|
||||
&model.ExternalAccount{},
|
||||
))
|
||||
|
||||
db.SetDB(testDB)
|
||||
return testDB
|
||||
}
|
||||
|
||||
type mockProvider struct{}
|
||||
|
||||
func (m *mockProvider) Name() string { return "custom" }
|
||||
func (m *mockProvider) GetAuthURL(state string) string {
|
||||
return "https://custom.com/auth?state=" + state
|
||||
}
|
||||
func (m *mockProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) {
|
||||
return &contracts.OAuthUserInfoDTO{
|
||||
ID: 555,
|
||||
Username: "custom_user",
|
||||
Email: "custom@example.com",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAuthPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
testDB := setupTestDB(t)
|
||||
|
||||
p := auth.New()
|
||||
assert.Equal(t, "auth", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
// Test AuthService injection
|
||||
authSvc, err := core.Inject[contracts.AuthService](ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authSvc.RequireAuthMiddleware())
|
||||
assert.NotNil(t, authSvc.RequireAdminMiddleware())
|
||||
|
||||
// Test AuthRegistry injection
|
||||
authReg, err := core.Inject[contracts.AuthRegistry](ctx)
|
||||
require.NoError(t, err)
|
||||
authReg.RegisterOAuthProvider("custom", &mockProvider{})
|
||||
prov, ok := authReg.GetOAuthProvider("custom")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "custom", prov.Name())
|
||||
|
||||
// Test User Token Verification with dummy token
|
||||
user := model.User{
|
||||
ID: 101,
|
||||
Username: "token_user",
|
||||
IsActive: true,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&user).Error)
|
||||
|
||||
tokenStr := "test-secret-token-123456"
|
||||
tokenHash := model.HashToken(tokenStr)
|
||||
tokenRecord := model.AccessToken{
|
||||
ID: 201,
|
||||
UserID: user.ID,
|
||||
TokenHash: tokenHash,
|
||||
Name: "test-token",
|
||||
IsAdmin: false,
|
||||
}
|
||||
require.NoError(t, testDB.Create(&tokenRecord).Error)
|
||||
|
||||
userDTO, err := authSvc.VerifyToken(context.Background(), tokenStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, userDTO.ID)
|
||||
assert.Equal(t, "token_user", userDTO.Username)
|
||||
|
||||
// Empty token fails
|
||||
_, err = authSvc.VerifyToken(context.Background(), "")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Revoke sessions
|
||||
require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID))
|
||||
|
||||
// GetCurrentUser from context
|
||||
userCtx := context.WithValue(context.Background(), "user_obj", userDTO)
|
||||
current, err := authSvc.GetCurrentUser(userCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, current.ID)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package auth provides authentication, OAuth, session management, and access token domain services.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func toUserDTO(u *model.User) *contracts.UserDTO {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &contracts.UserDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Email: u.Email,
|
||||
AvatarURL: u.AvatarURL,
|
||||
IsActive: u.IsActive,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Bio: u.Bio,
|
||||
Phone: u.Phone,
|
||||
Gender: u.Gender,
|
||||
Website: u.Website,
|
||||
Location: u.Location,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type authServiceImpl struct{}
|
||||
|
||||
func newAuthService() contracts.AuthService {
|
||||
return &authServiceImpl{}
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RequireAuthMiddleware() any {
|
||||
return oauth.LoginRequired()
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RequireAdminMiddleware() any {
|
||||
return admin.LoginAdminRequired()
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
if u, ok := oauth.GetFromContext[*model.User](ginCtx, oauth.UserObjKey); ok && u != nil {
|
||||
return toUserDTO(u), nil
|
||||
}
|
||||
}
|
||||
|
||||
if v := ctx.Value(oauth.UserObjKey); v != nil {
|
||||
if u, ok := v.(*model.User); ok && u != nil {
|
||||
return toUserDTO(u), nil
|
||||
}
|
||||
if u, ok := v.(*contracts.UserDTO); ok && u != nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("auth: user not found in context")
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) {
|
||||
if token == "" {
|
||||
return nil, errors.New("auth: empty token")
|
||||
}
|
||||
|
||||
tokenHash := model.HashToken(token)
|
||||
tokenRecord, err := oauth.GetCachedToken(ctx, tokenHash)
|
||||
if err != nil {
|
||||
dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenRecord = &dbToken
|
||||
}
|
||||
|
||||
user, err := oauth.GetCachedUser(ctx, tokenRecord.UserID)
|
||||
if err != nil || !user.IsActive {
|
||||
dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user = &dbUser
|
||||
}
|
||||
|
||||
if user.Username == "system" {
|
||||
return nil, errors.New("auth: system user token not allowed")
|
||||
}
|
||||
|
||||
return toUserDTO(user), nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) {
|
||||
// Session creation helper
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error {
|
||||
oauth.InvalidateCachedUser(ctx, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type authRegistryImpl struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]contracts.OAuthProvider
|
||||
}
|
||||
|
||||
func newAuthRegistry() contracts.AuthRegistry {
|
||||
return &authRegistryImpl{
|
||||
providers: make(map[string]contracts.OAuthProvider),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers[name] = provider
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (r *authRegistryImpl) ListOAuthProviders() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
res := make([]string, 0, len(r.providers))
|
||||
for name := range r.providers {
|
||||
res = append(res, name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user