feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins

This commit is contained in:
ryan
2026-08-27 23:59:38 +08:00
parent 75f226cfbf
commit a4c3f70f0b
18 changed files with 2082 additions and 0 deletions
@@ -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
+112
View File
@@ -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
}
+113
View File
@@ -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)
}
+146
View File
@@ -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
}