mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS w_users (
|
||||
id BIGINT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password VARCHAR(255),
|
||||
nickname VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
avatar_url VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
bio VARCHAR(500),
|
||||
phone VARCHAR(32),
|
||||
gender VARCHAR(16),
|
||||
website VARCHAR(255),
|
||||
location VARCHAR(255),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS w_access_tokens (
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS w_access_tokens;
|
||||
DROP TABLE IF EXISTS w_users;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,130 @@
|
||||
// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var userMigrations embed.FS
|
||||
|
||||
// Option configures the user plugin.
|
||||
type Option func(*Plugin)
|
||||
|
||||
// WithUserService sets a custom UserService implementation.
|
||||
func WithUserService(svc contracts.UserService) Option {
|
||||
return func(p *Plugin) {
|
||||
p.userSvc = svc
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin implements core.Plugin to provide user account and credential domain services.
|
||||
type Plugin struct {
|
||||
userSvc contracts.UserService
|
||||
}
|
||||
|
||||
// New creates a new user 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 user domain plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return "user"
|
||||
}
|
||||
|
||||
// Manifest returns the plugin metadata.
|
||||
func (p *Plugin) Manifest() core.Manifest {
|
||||
return core.Manifest{
|
||||
Name: "user",
|
||||
Version: "1.0.0",
|
||||
Description: "User profiles, credentials, role management, and access token domain plugin",
|
||||
Author: "Wavelet Team",
|
||||
}
|
||||
}
|
||||
|
||||
// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 1. Register migrations
|
||||
ctx.Migrations().Register("user", userMigrations)
|
||||
|
||||
// 2. Initialize and provide UserService
|
||||
if p.userSvc == nil {
|
||||
p.userSvc = newUserService()
|
||||
}
|
||||
core.Provide[contracts.UserService](ctx, p.userSvc)
|
||||
|
||||
// 3. Register HTTP Routes
|
||||
userGroup := ctx.Router().Group("/api/v1/user")
|
||||
{
|
||||
userGroup.POST("/login", user.Login)
|
||||
userGroup.POST("/register", user.Register)
|
||||
userGroup.GET("/logout", user.Logout)
|
||||
userGroup.POST("/send-email-code", user.SendEmailCode)
|
||||
userGroup.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||
userGroup.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
|
||||
|
||||
// Access Tokens
|
||||
tokensGroup := userGroup.Group("/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth())
|
||||
{
|
||||
tokensGroup.GET("", user.ListAccessTokens)
|
||||
tokensGroup.POST("", user.CreateAccessToken)
|
||||
tokensGroup.DELETE("/:id", user.DeleteAccessToken)
|
||||
tokensGroup.POST("/:id/rotate", user.RotateAccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register Asynq background tasks
|
||||
ctx.Task().Register("user:send_email_code", func(c context.Context, t *asynq.Task) error {
|
||||
// Asynq background task handler
|
||||
return nil
|
||||
}, extpoints.WithTaskRetry(3))
|
||||
|
||||
ctx.Task().Register("user:cleanup_inactive", func(c context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
// 5. Register Cron Schedules
|
||||
ctx.Schedule().RegisterCron("0 3 * * *", "user:daily_audit", map[string]string{"type": "audit"})
|
||||
|
||||
// 6. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.registration_enabled",
|
||||
Default: true,
|
||||
Description: "Whether new user registration is enabled",
|
||||
Type: "boolean",
|
||||
Category: "general",
|
||||
Public: true,
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.password_login_enabled",
|
||||
Default: true,
|
||||
Description: "Whether password login is enabled",
|
||||
Type: "boolean",
|
||||
Category: "general",
|
||||
Public: true,
|
||||
})
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
Key: "user.min_password_length",
|
||||
Default: 8,
|
||||
Description: "Minimum password length required for user accounts",
|
||||
Type: "integer",
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package user_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/user"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "user_test.db")
|
||||
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, testDB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.AccessToken{},
|
||||
))
|
||||
|
||||
db.SetDB(testDB)
|
||||
return testDB
|
||||
}
|
||||
|
||||
func TestUserPluginUnit(t *testing.T) {
|
||||
ctx := core.NewContext(context.Background())
|
||||
_ = setupTestDB(t)
|
||||
|
||||
p := user.New()
|
||||
assert.Equal(t, "user", p.Name())
|
||||
assert.Equal(t, "1.0.0", p.Manifest().Version)
|
||||
require.NoError(t, p.Apply(ctx))
|
||||
|
||||
userSvc, err := core.Inject[contracts.UserService](ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, userSvc)
|
||||
|
||||
testCtx := context.Background()
|
||||
|
||||
// 1. Create User
|
||||
u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
|
||||
Username: "charlie",
|
||||
Password: "Password789!",
|
||||
Email: "charlie@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "charlie", u.Username)
|
||||
|
||||
// 2. Empty username error
|
||||
_, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{})
|
||||
assert.Error(t, err)
|
||||
|
||||
// 3. Verify Password
|
||||
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!"))
|
||||
assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong"))
|
||||
|
||||
// 4. Update Password with wrong old password
|
||||
err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Update Password success
|
||||
err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!"))
|
||||
|
||||
// 5. Update Profile
|
||||
nickname := "Charlie Brown"
|
||||
email := "charlie.new@example.com"
|
||||
gender := "male"
|
||||
website := "https://charlie.me"
|
||||
loc := "SF"
|
||||
updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{
|
||||
Nickname: &nickname,
|
||||
Email: &email,
|
||||
Gender: &gender,
|
||||
Website: &website,
|
||||
Location: &loc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Charlie Brown", updated.Nickname)
|
||||
assert.Equal(t, "charlie.new@example.com", updated.Email)
|
||||
assert.Equal(t, "male", updated.Gender)
|
||||
assert.Equal(t, "https://charlie.me", updated.Website)
|
||||
assert.Equal(t, "SF", updated.Location)
|
||||
|
||||
// 6. List and Status
|
||||
require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true))
|
||||
require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true))
|
||||
|
||||
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "")
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, total, int64(1))
|
||||
assert.NotEmpty(t, list)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Package user provides user profiles, credentials, role management, and access token domain services.
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
pkgu "github.com/Rain-kl/Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
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 userServiceImpl struct{}
|
||||
|
||||
func newUserService() contracts.UserService {
|
||||
return &userServiceImpl{}
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
|
||||
u, err := repository.GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
|
||||
u, err := repository.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) {
|
||||
var u model.User
|
||||
if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUserDTO(&u), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) {
|
||||
if req.Username == "" {
|
||||
return nil, errors.New("user: username cannot be empty")
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
ID: idgen.NextUint64ID(),
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsActive: true,
|
||||
IsAdmin: req.IsAdmin,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = req.Username
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
if err := user.SetEncryptedPassword(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := repository.CreateUser(ctx, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toUserDTO(&user), nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) {
|
||||
updates := make(map[string]any)
|
||||
if req.Nickname != nil {
|
||||
updates["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Email != nil {
|
||||
updates["email"] = *req.Email
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
updates["avatar_url"] = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
updates["bio"] = *req.Bio
|
||||
}
|
||||
if req.Phone != nil {
|
||||
updates["phone"] = *req.Phone
|
||||
}
|
||||
if req.Gender != nil {
|
||||
updates["gender"] = *req.Gender
|
||||
}
|
||||
if req.Website != nil {
|
||||
updates["website"] = *req.Website
|
||||
}
|
||||
if req.Location != nil {
|
||||
updates["location"] = *req.Location
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error {
|
||||
var user model.User
|
||||
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.CheckPassword(oldPassword) {
|
||||
return errors.New("user: incorrect old password")
|
||||
}
|
||||
|
||||
if err := user.SetEncryptedPassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"password": user.Password,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool {
|
||||
var user model.User
|
||||
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
pkgu.DummyCheckPassword(password)
|
||||
return false
|
||||
}
|
||||
return user.CheckPassword(password)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, ip string) error {
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_login_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
filter := repository.AdminUserListFilter{
|
||||
Username: keyword,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
total, users, err := repository.ListAdminUsers(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
dtos := make([]*contracts.UserDTO, 0, len(users))
|
||||
for i := range users {
|
||||
dtos = append(dtos, toUserDTO(&users[i]))
|
||||
}
|
||||
|
||||
return dtos, total, nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error {
|
||||
return repository.UpdateUserActive(ctx, id, active)
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error {
|
||||
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error
|
||||
}
|
||||
Reference in New Issue
Block a user