feat: complete Kra admin backend migration
This commit is contained in:
@@ -20,6 +20,15 @@ type MenuButton struct {
|
||||
Name string
|
||||
Description string
|
||||
MenuID uint
|
||||
AuthorityID uint
|
||||
}
|
||||
|
||||
type MenuParameter struct {
|
||||
ID uint
|
||||
MenuID uint
|
||||
Type string
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
type Department struct {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"github.com/go-kratos/kratos/v3/log"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Admin is a Admin model.
|
||||
type Admin struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
Access string
|
||||
Avatar string
|
||||
CreateTime time.Time
|
||||
UpdateTime time.Time
|
||||
}
|
||||
|
||||
// AdminRepo is a Greater repo.
|
||||
type AdminRepo interface {
|
||||
FindByID(context.Context, int64) (*Admin, error)
|
||||
FindByName(context.Context, string) (*Admin, error)
|
||||
FindByEmail(context.Context, string) (*Admin, error)
|
||||
ListAdmins(context.Context, ...ListOption) ([]*Admin, error)
|
||||
CreateAdmin(context.Context, *Admin) (*Admin, error)
|
||||
UpdateAdmin(context.Context, *Admin) (*Admin, error)
|
||||
DeleteAdmin(context.Context, int64) error
|
||||
}
|
||||
|
||||
// AdminUsecase is a Admin usecase.
|
||||
type AdminUsecase struct {
|
||||
admin AdminRepo
|
||||
}
|
||||
|
||||
// NewAdminUsecase new a Admin usecase.
|
||||
func NewAdminUsecase(repo AdminRepo) *AdminUsecase {
|
||||
return &AdminUsecase{admin: repo}
|
||||
}
|
||||
|
||||
// LoginByUsername logs in a user by username and password.
|
||||
func (uc *AdminUsecase) LoginByUsername(ctx context.Context, username, password string) (*Admin, error) {
|
||||
user, err := uc.admin.FindByName(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAdminNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// LoginByEmail logs in a user by email and password.
|
||||
func (uc *AdminUsecase) LoginByEmail(ctx context.Context, email, password string) (*Admin, error) {
|
||||
user, err := uc.admin.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAdminNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Logout logs out the current user.
|
||||
func (uc *AdminUsecase) Logout(ctx context.Context, adminID int64) error {
|
||||
admin, err := uc.admin.FindByID(ctx, adminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.InfoContext(ctx, "admin logged out", "name", admin.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current returns the current logged in user.
|
||||
func (uc *AdminUsecase) GetAdmin(ctx context.Context, id int64) (*Admin, error) {
|
||||
return uc.admin.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// ListAdmins lists admin users with pagination.
|
||||
func (uc *AdminUsecase) ListAdmins(ctx context.Context, opts ...ListOption) ([]*Admin, error) {
|
||||
admins, err := uc.admin.ListAdmins(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return admins, nil
|
||||
}
|
||||
|
||||
// CreateAdmin creates a new admin user.
|
||||
func (uc *AdminUsecase) CreateAdmin(ctx context.Context, admin *Admin) (*Admin, error) {
|
||||
if admin.Password != "" {
|
||||
hashed, err := hashPassword(admin.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admin.Password = hashed
|
||||
}
|
||||
return uc.admin.CreateAdmin(ctx, admin)
|
||||
}
|
||||
|
||||
// UpdateAdmin updates an existing admin user.
|
||||
func (uc *AdminUsecase) UpdateAdmin(ctx context.Context, admin *Admin) (*Admin, error) {
|
||||
// Empty password means "leave unchanged"; only hash when a new one is set.
|
||||
if admin.Password != "" {
|
||||
hashed, err := hashPassword(admin.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admin.Password = hashed
|
||||
}
|
||||
return uc.admin.UpdateAdmin(ctx, admin)
|
||||
}
|
||||
|
||||
// hashPassword hashes a plaintext password using bcrypt.
|
||||
func hashPassword(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashed), nil
|
||||
}
|
||||
|
||||
// DeleteAdmin deletes an admin user by ID.
|
||||
func (uc *AdminUsecase) DeleteAdmin(ctx context.Context, id int64) error {
|
||||
return uc.admin.DeleteAdmin(ctx, id)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// fakeAdminRepo is an in-memory AdminRepo for testing the usecase in
|
||||
// isolation from the data layer.
|
||||
type fakeAdminRepo struct {
|
||||
byName map[string]*Admin
|
||||
byEmail map[string]*Admin
|
||||
created []*Admin
|
||||
}
|
||||
|
||||
func newFakeAdminRepo() *fakeAdminRepo {
|
||||
return &fakeAdminRepo{
|
||||
byName: make(map[string]*Admin),
|
||||
byEmail: make(map[string]*Admin),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) FindByID(context.Context, int64) (*Admin, error) {
|
||||
return nil, ErrAdminNotFound
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) FindByName(_ context.Context, name string) (*Admin, error) {
|
||||
a, ok := r.byName[name]
|
||||
if !ok {
|
||||
return nil, ErrAdminNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) FindByEmail(_ context.Context, email string) (*Admin, error) {
|
||||
a, ok := r.byEmail[email]
|
||||
if !ok {
|
||||
return nil, ErrAdminNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) ListAdmins(context.Context, ...ListOption) ([]*Admin, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) CreateAdmin(_ context.Context, a *Admin) (*Admin, error) {
|
||||
r.created = append(r.created, a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) UpdateAdmin(_ context.Context, a *Admin) (*Admin, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeAdminRepo) DeleteAdmin(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedAdmin inserts an admin with a bcrypt-hashed password.
|
||||
func seedAdmin(t *testing.T, repo *fakeAdminRepo, name, email, password string) *Admin {
|
||||
t.Helper()
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
a := &Admin{ID: 1, Name: name, Email: email, Password: string(hashed)}
|
||||
repo.byName[name] = a
|
||||
repo.byEmail[email] = a
|
||||
return a
|
||||
}
|
||||
|
||||
func TestLoginByUsername_Success(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
got, err := uc.LoginByUsername(context.Background(), "admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("expected login success, got error: %v", err)
|
||||
}
|
||||
if got.Name != "admin" {
|
||||
t.Fatalf("expected admin user, got %q", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginByEmail_Success(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
got, err := uc.LoginByEmail(context.Background(), "admin@example.com", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("expected login success, got error: %v", err)
|
||||
}
|
||||
if got.Email != "admin@example.com" {
|
||||
t.Fatalf("expected admin user, got %q", got.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginByUsername_WrongPassword(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
seedAdmin(t, repo, "admin", "admin@example.com", "admin")
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
_, err := uc.LoginByUsername(context.Background(), "admin", "wrong")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong password, got nil")
|
||||
}
|
||||
if errors.Code(err) != 401 {
|
||||
t.Fatalf("expected 401 unauthorized, got code %d (%v)", errors.Code(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginByUsername_UserNotFound(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
_, err := uc.LoginByUsername(context.Background(), "ghost", "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown user, got nil")
|
||||
}
|
||||
// Must be the same opaque 401 as a wrong password, to avoid user enumeration.
|
||||
if errors.Code(err) != 401 {
|
||||
t.Fatalf("expected 401 unauthorized, got code %d (%v)", errors.Code(err), err)
|
||||
}
|
||||
if errors.Is(err, ErrAdminNotFound) {
|
||||
t.Fatal("login must not leak ErrAdminNotFound for unknown users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdmin_HashesPassword(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
_, err := uc.CreateAdmin(context.Background(), &Admin{Name: "bob", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("create admin: %v", err)
|
||||
}
|
||||
if len(repo.created) != 1 {
|
||||
t.Fatalf("expected 1 created admin, got %d", len(repo.created))
|
||||
}
|
||||
stored := repo.created[0].Password
|
||||
if stored == "secret" {
|
||||
t.Fatal("password was stored in plaintext")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte("secret")); err != nil {
|
||||
t.Fatalf("stored password is not a valid bcrypt hash of the input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAdmin_EmptyPasswordLeftUnchanged(t *testing.T) {
|
||||
repo := newFakeAdminRepo()
|
||||
uc := NewAdminUsecase(repo)
|
||||
|
||||
got, err := uc.UpdateAdmin(context.Background(), &Admin{ID: 1, Name: "bob", Password: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("update admin: %v", err)
|
||||
}
|
||||
if got.Password != "" {
|
||||
t.Fatalf("empty password must be passed through unchanged, got %q", got.Password)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,4 +3,4 @@ package biz
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(NewAdminUsecase, NewSystemUsecase, NewAccessUsecase, NewSettingsUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewTaskUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase)
|
||||
var ProviderSet = wire.NewSet(NewSystemUsecase, NewAccessUsecase, NewSettingsUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewTaskUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
v1 "kra/api/kratos/admin/v1"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAdminNotFound error admin not found.
|
||||
ErrAdminNotFound = errors.NotFound(v1.ErrorReason_ADMIN_NOT_FOUND.String(), "admin not found")
|
||||
ErrUserNotFound = errors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
// ErrInvalidCredentials is returned for any failed login, regardless of
|
||||
// whether the user exists, to avoid leaking which accounts are registered.
|
||||
ErrInvalidCredentials = errors.Unauthorized(v1.ErrorReason_INVALID_CREDENTIALS.String(), "invalid credentials")
|
||||
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
|
||||
)
|
||||
|
||||
@@ -67,6 +67,8 @@ type MediaRepo interface {
|
||||
UpsertChunk(context.Context, uint, *UploadChunk) error
|
||||
ListChunks(context.Context, uint) ([]*UploadChunk, error)
|
||||
DeleteChunks(context.Context, uint) error
|
||||
StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error)
|
||||
DeleteUploadData(context.Context, uint) error
|
||||
}
|
||||
type MediaUsecase struct {
|
||||
repo MediaRepo
|
||||
@@ -225,6 +227,24 @@ func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint)
|
||||
_ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID))
|
||||
return uc.repo.DeleteUploadSession(ctx, uploadID)
|
||||
}
|
||||
func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error {
|
||||
if ttlHours <= 0 {
|
||||
ttlHours = 24
|
||||
}
|
||||
ids, err := uc.repo.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err = uc.repo.DeleteUploadData(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", id)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) {
|
||||
return uc.files.List(ctx, prefix, cursor, limit)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ type SettingsRepo interface {
|
||||
CreateAPIToken(context.Context, *APIToken) error
|
||||
ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error)
|
||||
DisableAPIToken(context.Context, uint) (string, error)
|
||||
BlacklistToken(context.Context, string) error
|
||||
IsTokenDisabled(context.Context, string) (bool, error)
|
||||
SecurityConfig(context.Context) (*SecurityConfig, error)
|
||||
SaveSecurityConfig(context.Context, *SecurityConfig) error
|
||||
|
||||
+12
-1
@@ -36,7 +36,8 @@ type UserListFilter struct {
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver, Host, Port, User, Password, Name, Path, Config, AdminPassword string
|
||||
Driver, Host, Port, User, Password, Name, Path, Config, Template, AdminPassword string
|
||||
APIs []*API
|
||||
}
|
||||
|
||||
type Authority struct {
|
||||
@@ -64,6 +65,7 @@ type Menu struct {
|
||||
TransitionType string
|
||||
Children []*Menu
|
||||
Buttons []*MenuButton
|
||||
Parameters []*MenuParameter
|
||||
}
|
||||
|
||||
type SystemRepo interface {
|
||||
@@ -82,6 +84,8 @@ type SystemRepo interface {
|
||||
SetUserSetting(context.Context, uint, map[string]any) error
|
||||
ListAuthorities(context.Context) ([]*Authority, error)
|
||||
PersistConfig(context.Context) error
|
||||
PersistAdminConfig(context.Context, []byte) error
|
||||
ReloadConfig(context.Context) error
|
||||
}
|
||||
|
||||
type SystemUsecase struct {
|
||||
@@ -103,6 +107,9 @@ func (uc *SystemUsecase) CacheSet(ctx context.Context, key, value string, expira
|
||||
func (uc *SystemUsecase) CacheDelete(ctx context.Context, key string) error {
|
||||
return uc.cache.Delete(ctx, key)
|
||||
}
|
||||
func (uc *SystemUsecase) CacheIncrement(ctx context.Context, key string, expiration time.Duration) (int64, error) {
|
||||
return uc.cache.Increment(ctx, key, expiration)
|
||||
}
|
||||
|
||||
func (uc *SystemUsecase) IsInitialized(ctx context.Context) (bool, error) {
|
||||
return uc.repo.IsInitialized(ctx)
|
||||
@@ -114,6 +121,10 @@ func (uc *SystemUsecase) Initialize(ctx context.Context, config *DatabaseConfig)
|
||||
return uc.repo.Initialize(ctx, config)
|
||||
}
|
||||
func (uc *SystemUsecase) PersistConfig(ctx context.Context) error { return uc.repo.PersistConfig(ctx) }
|
||||
func (uc *SystemUsecase) PersistAdminConfig(ctx context.Context, value []byte) error {
|
||||
return uc.repo.PersistAdminConfig(ctx, value)
|
||||
}
|
||||
func (uc *SystemUsecase) ReloadConfig(ctx context.Context) error { return uc.repo.ReloadConfig(ctx) }
|
||||
|
||||
func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (*User, error) {
|
||||
u, err := uc.repo.FindUserByUsername(ctx, username)
|
||||
|
||||
@@ -37,7 +37,8 @@ type TaskRepo interface {
|
||||
ToggleTask(context.Context, uint, bool) error
|
||||
RecordTaskLog(context.Context, *TimedTaskLog) error
|
||||
ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error)
|
||||
CleanupLogs(context.Context, time.Time) error
|
||||
CleanupLogs(context.Context) error
|
||||
TaskNameExists(context.Context, string, uint) (bool, error)
|
||||
}
|
||||
type TaskUsecase struct{ repo TaskRepo }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user