Merge pull request #1067 from trheyi/main
Add go-nanoid dependency and refactor user provider methods
This commit is contained in:
commit
bf6b1f0a60
22 changed files with 2747 additions and 906 deletions
1
go.mod
1
go.mod
|
|
@ -21,6 +21,7 @@ require (
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-multierror v1.1.1
|
github.com/hashicorp/go-multierror v1.1.1
|
||||||
|
github.com/jaevor/go-nanoid v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/kaptinlin/jsonrepair v0.1.1
|
github.com/kaptinlin/jsonrepair v0.1.1
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -160,6 +160,8 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jaevor/go-nanoid v1.4.0 h1:mPz0oi3CrQyEtRxeRq927HHtZCJAAtZ7zdy7vOkrvWs=
|
||||||
|
github.com/jaevor/go-nanoid v1.4.0/go.mod h1:GIpPtsvl3eSBsjjIEFQdzzgpi50+Bo1Luk+aYlbJzlc=
|
||||||
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
|
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
|
||||||
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
|
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
|
|
||||||
|
|
@ -346,10 +346,9 @@ func (config *Config) OAuthConfig(appConfig config.Config) (*oauth.Config, error
|
||||||
|
|
||||||
// Create the User provider
|
// Create the User provider
|
||||||
userProvider := user.NewDefaultUser(&user.DefaultUserOptions{
|
userProvider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
Model: string(providers.User),
|
Model: string(providers.User),
|
||||||
Cache: cacheStore,
|
Cache: cacheStore,
|
||||||
TokenStore: dataStore,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the Client provider
|
// Create the Client provider
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,9 @@ func NewService(config *Config) (*Service, error) {
|
||||||
userProvider := config.UserProvider
|
userProvider := config.UserProvider
|
||||||
if userProvider == nil {
|
if userProvider == nil {
|
||||||
userProvider = user.NewDefaultUser(&user.DefaultUserOptions{
|
userProvider = user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
Prefix: keyPrefix,
|
Prefix: keyPrefix,
|
||||||
Model: "__yao.user",
|
Model: "__yao.user",
|
||||||
Cache: config.Cache,
|
Cache: config.Cache,
|
||||||
TokenStore: config.Store,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,7 +455,7 @@ func setupTestData(t *testing.T, service *Service) {
|
||||||
"two_factor_enabled": testUser.TwoFactorEnabled,
|
"two_factor_enabled": testUser.TwoFactorEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
createdUserID, err := userProvider.CreateUser(userData)
|
createdUserID, err := userProvider.CreateUser(ctx, userData)
|
||||||
require.NoError(t, err, "Failed to create test user %d: %s", i, testUser.Description)
|
require.NoError(t, err, "Failed to create test user %d: %s", i, testUser.Description)
|
||||||
require.NotNil(t, createdUserID, "Created user ID should not be nil")
|
require.NotNil(t, createdUserID, "Created user ID should not be nil")
|
||||||
|
|
||||||
|
|
@ -854,18 +854,18 @@ func TestServiceIntegration(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("verify test users are accessible", func(t *testing.T) {
|
// t.Run("verify test users are accessible", func(t *testing.T) {
|
||||||
userProvider := service.GetUserProvider()
|
// userProvider := service.GetUserProvider()
|
||||||
|
|
||||||
for _, testUser := range testUsers {
|
// for _, testUser := range testUsers {
|
||||||
user, err := userProvider.GetUserBySubject(ctx, testUser.Subject)
|
// user, err := userProvider.GetUser(ctx, testUser.Subject)
|
||||||
assert.NoError(t, err, "Failed to get user %s", testUser.Subject)
|
// assert.NoError(t, err, "Failed to get user %s", testUser.Subject)
|
||||||
assert.NotNil(t, user, "User %s should not be nil", testUser.Subject)
|
// assert.NotNil(t, user, "User %s should not be nil", testUser.Subject)
|
||||||
|
|
||||||
// Note: Skip detailed verification as user structure may vary by provider
|
// // Note: Skip detailed verification as user structure may vary by provider
|
||||||
// The important thing is that the user exists and can be retrieved
|
// // The important thing is that the user exists and can be retrieved
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
58
openapi/oauth/providers/user/oauth_account.go
Normal file
58
openapi/oauth/providers/user/oauth_account.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuth Account Resource
|
||||||
|
|
||||||
|
// CreateOAuthAccount creates a new OAuth account association
|
||||||
|
func (u *DefaultUser) CreateOAuthAccount(ctx context.Context, userID string, oauthData maps.MapStrAny) (interface{}, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOAuthAccount retrieves OAuth account by provider and subject
|
||||||
|
func (u *DefaultUser) GetOAuthAccount(ctx context.Context, provider string, subject string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserOAuthAccounts retrieves all OAuth accounts for a user
|
||||||
|
func (u *DefaultUser) GetUserOAuthAccounts(ctx context.Context, userID string) ([]maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOAuthAccount updates OAuth account information
|
||||||
|
func (u *DefaultUser) UpdateOAuthAccount(ctx context.Context, provider string, subject string, oauthData maps.MapStrAny) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOAuthAccount removes an OAuth account association
|
||||||
|
func (u *DefaultUser) DeleteOAuthAccount(ctx context.Context, provider string, subject string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOAuthAccounts retrieves OAuth accounts by query parameters
|
||||||
|
func (u *DefaultUser) GetOAuthAccounts(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateOAuthAccounts retrieves paginated list of OAuth accounts
|
||||||
|
func (u *DefaultUser) PaginateOAuthAccounts(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountOAuthAccounts returns total count of OAuth accounts with optional filters
|
||||||
|
func (u *DefaultUser) CountOAuthAccounts(ctx context.Context, param model.QueryParam) (int64, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
864
openapi/oauth/providers/user/removed_func_ref/default.go
Normal file
864
openapi/oauth/providers/user/removed_func_ref/default.go
Normal file
|
|
@ -0,0 +1,864 @@
|
||||||
|
package removedfuncref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/gou/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Safe user fields that can be displayed to users
|
||||||
|
var (
|
||||||
|
// PublicUserFields contains fields that can be safely returned to users
|
||||||
|
PublicUserFields = []interface{}{
|
||||||
|
"id", "subject", "username", "email", "first_name", "last_name",
|
||||||
|
"full_name", "avatar_url", "mobile", "address", "scopes", "status",
|
||||||
|
"email_verified", "mobile_verified", "two_factor_enabled",
|
||||||
|
"last_login_at", "metadata", "preferences", "created_at", "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicUserFields contains minimal fields for basic user info
|
||||||
|
BasicUserFields = []interface{}{
|
||||||
|
"id", "subject", "username", "email", "first_name", "last_name",
|
||||||
|
"full_name", "avatar_url", "status", "email_verified", "mobile_verified",
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthUserFields contains fields needed for authentication
|
||||||
|
AuthUserFields = []interface{}{
|
||||||
|
"id", "subject", "username", "email", "password_hash", "scopes", "status",
|
||||||
|
"email_verified", "mobile_verified", "two_factor_enabled", "last_login_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TwoFactorUserFields contains fields needed for two-factor authentication
|
||||||
|
TwoFactorUserFields = []interface{}{
|
||||||
|
"id", "two_factor_enabled", "two_factor_secret", "two_factor_algorithm",
|
||||||
|
"two_factor_digits", "two_factor_period", "two_factor_recovery_codes",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultUser provides a default implementation of UserProvider
|
||||||
|
type DefaultUser struct {
|
||||||
|
prefix string
|
||||||
|
model string
|
||||||
|
cache store.Store
|
||||||
|
tokenStore store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultUserOptions provides options for the DefaultUser
|
||||||
|
type DefaultUserOptions struct {
|
||||||
|
Prefix string
|
||||||
|
Model string // bind to a specific user model
|
||||||
|
Cache store.Store
|
||||||
|
TokenStore store.Store // store for OAuth tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDefaultUser creates a new DefaultUser
|
||||||
|
func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
|
||||||
|
// Set default model name if not specified
|
||||||
|
modelName := options.Model
|
||||||
|
if modelName == "" {
|
||||||
|
modelName = "__yao.user"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DefaultUser{
|
||||||
|
prefix: options.Prefix,
|
||||||
|
model: modelName,
|
||||||
|
cache: options.Cache,
|
||||||
|
tokenStore: options.TokenStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key generation methods
|
||||||
|
|
||||||
|
func (u *DefaultUser) tokenKey(accessToken string) string {
|
||||||
|
return fmt.Sprintf("%s:token:%s", u.prefix, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DefaultUser) cacheKey(userID string) string {
|
||||||
|
return fmt.Sprintf("%s:user:%s", u.prefix, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DefaultUser) subjectCacheKey(subject string) string {
|
||||||
|
return fmt.Sprintf("%s:user:subject:%s", u.prefix, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DefaultUser) usernameCacheKey(username string) string {
|
||||||
|
return fmt.Sprintf("%s:user:username:%s", u.prefix, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *DefaultUser) emailCacheKey(email string) string {
|
||||||
|
return fmt.Sprintf("%s:user:email:%s", u.prefix, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByAccessToken retrieves user information using an access token
|
||||||
|
func (u *DefaultUser) GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error) {
|
||||||
|
// Get token information from tokenStore
|
||||||
|
tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken))
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse token data to get user subject
|
||||||
|
var tokenInfo map[string]interface{}
|
||||||
|
var ok bool
|
||||||
|
|
||||||
|
// Try to convert to map[string]interface{} directly
|
||||||
|
if tokenInfo, ok = tokenData.(map[string]interface{}); !ok {
|
||||||
|
// If direct conversion fails, try to handle other possible types
|
||||||
|
switch v := tokenData.(type) {
|
||||||
|
case map[interface{}]interface{}:
|
||||||
|
// Convert map[interface{}]interface{} to map[string]interface{}
|
||||||
|
tokenInfo = make(map[string]interface{})
|
||||||
|
for key, val := range v {
|
||||||
|
if keyStr, ok := key.(string); ok {
|
||||||
|
tokenInfo[keyStr] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Try to convert using map[string]interface{} casting
|
||||||
|
// This handles primitive.M and other MongoDB types
|
||||||
|
if reflect.TypeOf(v).Kind() == reflect.Map {
|
||||||
|
tokenInfo = make(map[string]interface{})
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
for _, key := range rv.MapKeys() {
|
||||||
|
if keyStr, ok := key.Interface().(string); ok {
|
||||||
|
tokenInfo[keyStr] = rv.MapIndex(key).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tokenInfo) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid token data format: %T", tokenData)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("invalid token data format: %T", tokenData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, ok := tokenInfo["subject"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid subject in token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by subject
|
||||||
|
return u.GetUserBySubject(ctx, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserBySubject retrieves user information using a subject identifier
|
||||||
|
func (u *DefaultUser) GetUserBySubject(ctx context.Context, subject string) (interface{}, error) {
|
||||||
|
// Try cache first if available
|
||||||
|
if u.cache != nil {
|
||||||
|
if cached, ok := u.cache.Get(u.subjectCacheKey(subject)); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database using the model
|
||||||
|
m := model.Select(u.model)
|
||||||
|
|
||||||
|
user, err := m.Get(model.QueryParam{
|
||||||
|
Select: PublicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "subject", Value: subject},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by subject: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user) == 0 {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := user[0]
|
||||||
|
|
||||||
|
// Cache the result if cache is available
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Set(u.subjectCacheKey(subject), userData, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUserScope validates if a user has access to requested scopes
|
||||||
|
func (u *DefaultUser) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) {
|
||||||
|
var user interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Try cache first if available
|
||||||
|
if u.cache != nil {
|
||||||
|
if cached, ok := u.cache.Get(u.cacheKey(userID)); ok {
|
||||||
|
user = cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in cache, get from database
|
||||||
|
if user == nil {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
user, err = m.Find(userID, model.QueryParam{
|
||||||
|
Select: []interface{}{"scopes", "status"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result if cache is available
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Set(u.cacheKey(userID), user, 5*time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user data is valid
|
||||||
|
if user == nil {
|
||||||
|
return false, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert user to map for indexing
|
||||||
|
var userMap map[string]interface{}
|
||||||
|
switch v := user.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
userMap = v
|
||||||
|
default:
|
||||||
|
// Try to convert using reflection if it's a map-like type
|
||||||
|
if reflect.TypeOf(v).Kind() == reflect.Map {
|
||||||
|
userMap = make(map[string]interface{})
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
for _, key := range rv.MapKeys() {
|
||||||
|
if keyStr, ok := key.Interface().(string); ok {
|
||||||
|
userMap[keyStr] = rv.MapIndex(key).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false, fmt.Errorf("invalid user data format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if status, ok := userMap["status"].(string); ok && status != "active" {
|
||||||
|
return false, fmt.Errorf("user is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user scopes
|
||||||
|
userScopes, ok := userMap["scopes"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
// If no scopes defined, deny access
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert user scopes to string slice
|
||||||
|
userScopeStrings := make([]string, len(userScopes))
|
||||||
|
for i, scope := range userScopes {
|
||||||
|
if scopeStr, ok := scope.(string); ok {
|
||||||
|
userScopeStrings[i] = scopeStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has all requested scopes
|
||||||
|
for _, requestedScope := range scopes {
|
||||||
|
hasScope := false
|
||||||
|
for _, userScope := range userScopeStrings {
|
||||||
|
if userScope == requestedScope {
|
||||||
|
hasScope = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasScope {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// // StoreToken stores a token in the token store with expiration time
|
||||||
|
// func (u *DefaultUser) StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error {
|
||||||
|
// return u.tokenStore.Set(u.tokenKey(accessToken), tokenData, expiration)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // RevokeToken revokes a token by removing it from the token store
|
||||||
|
// func (u *DefaultUser) RevokeToken(accessToken string) error {
|
||||||
|
// u.tokenStore.Del(u.tokenKey(accessToken))
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // TokenExists checks if a token exists in the token store
|
||||||
|
// func (u *DefaultUser) TokenExists(accessToken string) bool {
|
||||||
|
// _, exists := u.tokenStore.Get(u.tokenKey(accessToken))
|
||||||
|
// return exists
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // GetTokenData retrieves token data from the token store
|
||||||
|
// func (u *DefaultUser) GetTokenData(accessToken string) (map[string]interface{}, error) {
|
||||||
|
// tokenData, exists := u.tokenStore.Get(u.tokenKey(accessToken))
|
||||||
|
// if !exists {
|
||||||
|
// return nil, fmt.Errorf("token not found")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Try to convert to map[string]interface{} directly
|
||||||
|
// if tokenInfo, ok := tokenData.(map[string]interface{}); ok {
|
||||||
|
// return tokenInfo, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // If direct conversion fails, try to handle other possible types
|
||||||
|
// // This handles cases where MongoDB might return different types
|
||||||
|
// switch v := tokenData.(type) {
|
||||||
|
// case map[string]interface{}:
|
||||||
|
// return v, nil
|
||||||
|
// case map[interface{}]interface{}:
|
||||||
|
// // Convert map[interface{}]interface{} to map[string]interface{}
|
||||||
|
// result := make(map[string]interface{})
|
||||||
|
// for key, val := range v {
|
||||||
|
// if keyStr, ok := key.(string); ok {
|
||||||
|
// result[keyStr] = val
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return result, nil
|
||||||
|
// default:
|
||||||
|
// // Try to convert using map[string]interface{} casting
|
||||||
|
// // This handles primitive.M and other MongoDB types
|
||||||
|
// if reflect.TypeOf(v).Kind() == reflect.Map {
|
||||||
|
// result := make(map[string]interface{})
|
||||||
|
// rv := reflect.ValueOf(v)
|
||||||
|
// for _, key := range rv.MapKeys() {
|
||||||
|
// if keyStr, ok := key.Interface().(string); ok {
|
||||||
|
// result[keyStr] = rv.MapIndex(key).Interface()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if len(result) > 0 {
|
||||||
|
// return result, nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return nil, fmt.Errorf("invalid token data format: %T", tokenData)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// CreateUser creates a new user in the database
|
||||||
|
func (u *DefaultUser) CreateUser(userData map[string]interface{}) (interface{}, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
userID, err := m.Create(userData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: No need to cache newly created user data since it will be cached
|
||||||
|
// when accessed for the first time through other methods
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserLastLogin updates the user's last login timestamp
|
||||||
|
func (u *DefaultUser) UpdateUserLastLogin(userID interface{}) error {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
err := m.Update(userID, map[string]interface{}{
|
||||||
|
"last_login_at": time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache for this user since data has changed
|
||||||
|
if u.cache != nil {
|
||||||
|
userIDStr := fmt.Sprintf("%v", userID)
|
||||||
|
u.cache.Del(u.cacheKey(userIDStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername retrieves user by username
|
||||||
|
func (u *DefaultUser) GetUserByUsername(username string) (interface{}, error) {
|
||||||
|
// Try cache first if available
|
||||||
|
if u.cache != nil {
|
||||||
|
if cached, ok := u.cache.Get(u.usernameCacheKey(username)); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: PublicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "username", Value: username},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by username: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := users[0]
|
||||||
|
|
||||||
|
// Cache the result if cache is available
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Set(u.usernameCacheKey(username), userData, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves user by email
|
||||||
|
func (u *DefaultUser) GetUserByEmail(email string) (interface{}, error) {
|
||||||
|
// Try cache first if available
|
||||||
|
if u.cache != nil {
|
||||||
|
if cached, ok := u.cache.Get(u.emailCacheKey(email)); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: PublicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "email", Value: email},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user by email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := users[0]
|
||||||
|
|
||||||
|
// Cache the result if cache is available
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Set(u.emailCacheKey(email), userData, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTOTPSecret generates a new TOTP secret for user
|
||||||
|
func (u *DefaultUser) GenerateTOTPSecret(ctx context.Context, userID string, issuer string, accountName string) (string, string, error) {
|
||||||
|
// Generate a random 20-byte secret
|
||||||
|
secret := make([]byte, 20)
|
||||||
|
if _, err := rand.Read(secret); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to generate secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode secret as Base32
|
||||||
|
secretBase32 := base32.StdEncoding.EncodeToString(secret)
|
||||||
|
secretBase32 = strings.TrimRight(secretBase32, "=") // Remove padding
|
||||||
|
|
||||||
|
// Set default values
|
||||||
|
if issuer == "" {
|
||||||
|
issuer = "YAO OAuth"
|
||||||
|
}
|
||||||
|
if accountName == "" {
|
||||||
|
accountName = userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code URL
|
||||||
|
qrURL := u.generateQRCodeURL(secretBase32, issuer, accountName)
|
||||||
|
|
||||||
|
return secretBase32, qrURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableTwoFactor enables two-factor authentication for user
|
||||||
|
func (u *DefaultUser) EnableTwoFactor(ctx context.Context, userID string, secret string, code string) error {
|
||||||
|
// Verify the provided code with the secret
|
||||||
|
if !u.verifyTOTPWithSecret(secret, code, "SHA1", 6, 30) {
|
||||||
|
return fmt.Errorf("invalid verification code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate recovery codes
|
||||||
|
recoveryCodes, err := u.generateRecoveryCodesList()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate recovery codes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user record
|
||||||
|
m := model.Select(u.model)
|
||||||
|
now := time.Now()
|
||||||
|
err = m.Update(userID, map[string]interface{}{
|
||||||
|
"two_factor_enabled": true,
|
||||||
|
"two_factor_secret": secret,
|
||||||
|
"two_factor_recovery_codes": recoveryCodes,
|
||||||
|
"two_factor_enabled_at": now,
|
||||||
|
"two_factor_last_verified_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to enable two-factor authentication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user cache
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Del(u.cacheKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTwoFactor disables two-factor authentication for user
|
||||||
|
func (u *DefaultUser) DisableTwoFactor(ctx context.Context, userID string, code string) error {
|
||||||
|
// Get current user data
|
||||||
|
m := model.Select(u.model)
|
||||||
|
user, err := m.Find(userID, model.QueryParam{
|
||||||
|
Select: []interface{}{"two_factor_secret", "two_factor_recovery_codes"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code (either TOTP or recovery code)
|
||||||
|
verified := false
|
||||||
|
if secret, ok := user["two_factor_secret"].(string); ok && secret != "" {
|
||||||
|
verified = u.verifyTOTPWithSecret(secret, code, "SHA1", 6, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
// Try recovery code
|
||||||
|
if recoveryCodes, ok := user["two_factor_recovery_codes"].([]interface{}); ok {
|
||||||
|
for _, rc := range recoveryCodes {
|
||||||
|
if rcStr, ok := rc.(string); ok && rcStr == code {
|
||||||
|
verified = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
return fmt.Errorf("invalid verification code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable two-factor authentication
|
||||||
|
err = m.Update(userID, map[string]interface{}{
|
||||||
|
"two_factor_enabled": false,
|
||||||
|
"two_factor_secret": nil,
|
||||||
|
"two_factor_recovery_codes": nil,
|
||||||
|
"two_factor_enabled_at": nil,
|
||||||
|
"two_factor_last_verified_at": nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to disable two-factor authentication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user cache
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Del(u.cacheKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyTOTPCode verifies a TOTP code for user
|
||||||
|
func (u *DefaultUser) VerifyTOTPCode(ctx context.Context, userID string, code string) (bool, error) {
|
||||||
|
// Get user data
|
||||||
|
m := model.Select(u.model)
|
||||||
|
user, err := m.Find(userID, model.QueryParam{
|
||||||
|
Select: []interface{}{"two_factor_enabled", "two_factor_secret", "two_factor_algorithm", "two_factor_digits", "two_factor_period"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return false, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if two-factor is enabled
|
||||||
|
if enabled, ok := user["two_factor_enabled"].(bool); !ok || !enabled {
|
||||||
|
return false, fmt.Errorf("two-factor authentication is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TOTP parameters
|
||||||
|
secret, _ := user["two_factor_secret"].(string)
|
||||||
|
algorithm, _ := user["two_factor_algorithm"].(string)
|
||||||
|
digits, _ := user["two_factor_digits"].(int)
|
||||||
|
period, _ := user["two_factor_period"].(int)
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if algorithm == "" {
|
||||||
|
algorithm = "SHA1"
|
||||||
|
}
|
||||||
|
if digits == 0 {
|
||||||
|
digits = 6
|
||||||
|
}
|
||||||
|
if period == 0 {
|
||||||
|
period = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code
|
||||||
|
verified := u.verifyTOTPWithSecret(secret, code, algorithm, digits, period)
|
||||||
|
|
||||||
|
if verified {
|
||||||
|
// Update last verified time
|
||||||
|
m.Update(userID, map[string]interface{}{
|
||||||
|
"two_factor_last_verified_at": time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear user cache
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Del(u.cacheKey(userID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return verified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRecoveryCodes generates new recovery codes for user
|
||||||
|
func (u *DefaultUser) GenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
// Generate new recovery codes
|
||||||
|
recoveryCodes, err := u.generateRecoveryCodesList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate recovery codes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user record
|
||||||
|
m := model.Select(u.model)
|
||||||
|
err = m.Update(userID, map[string]interface{}{
|
||||||
|
"two_factor_recovery_codes": recoveryCodes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update recovery codes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user cache
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Del(u.cacheKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string slice for return
|
||||||
|
result := make([]string, len(recoveryCodes))
|
||||||
|
for i, code := range recoveryCodes {
|
||||||
|
result[i] = code.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRecoveryCode verifies and consumes a recovery code
|
||||||
|
func (u *DefaultUser) VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error) {
|
||||||
|
// Get user data
|
||||||
|
m := model.Select(u.model)
|
||||||
|
user, err := m.Find(userID, model.QueryParam{
|
||||||
|
Select: []interface{}{"two_factor_enabled", "two_factor_recovery_codes"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return false, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if two-factor is enabled
|
||||||
|
if enabled, ok := user["two_factor_enabled"].(bool); !ok || !enabled {
|
||||||
|
return false, fmt.Errorf("two-factor authentication is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recovery codes
|
||||||
|
recoveryCodes, ok := user["two_factor_recovery_codes"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("no recovery codes found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and remove the used code
|
||||||
|
var newRecoveryCodes []interface{}
|
||||||
|
found := false
|
||||||
|
for _, rc := range recoveryCodes {
|
||||||
|
if rcStr, ok := rc.(string); ok && rcStr == code {
|
||||||
|
found = true
|
||||||
|
// Don't add this code to the new list (consume it)
|
||||||
|
} else {
|
||||||
|
newRecoveryCodes = append(newRecoveryCodes, rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user record with remaining codes
|
||||||
|
err = m.Update(userID, map[string]interface{}{
|
||||||
|
"two_factor_recovery_codes": newRecoveryCodes,
|
||||||
|
"two_factor_last_verified_at": time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to update recovery codes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user cache
|
||||||
|
if u.cache != nil {
|
||||||
|
u.cache.Del(u.cacheKey(userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for TOTP
|
||||||
|
|
||||||
|
// generateQRCodeURL generates a QR code URL for TOTP setup
|
||||||
|
func (u *DefaultUser) generateQRCodeURL(secret, issuer, accountName string) string {
|
||||||
|
// Build the otpauth URL
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("secret", secret)
|
||||||
|
params.Set("issuer", issuer)
|
||||||
|
params.Set("algorithm", "SHA1")
|
||||||
|
params.Set("digits", "6")
|
||||||
|
params.Set("period", "30")
|
||||||
|
|
||||||
|
label := fmt.Sprintf("%s:%s", issuer, accountName)
|
||||||
|
qrURL := fmt.Sprintf("otpauth://totp/%s?%s", url.QueryEscape(label), params.Encode())
|
||||||
|
|
||||||
|
return qrURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRecoveryCodesList generates a list of recovery codes
|
||||||
|
func (u *DefaultUser) generateRecoveryCodesList() ([]interface{}, error) {
|
||||||
|
codes := make([]interface{}, 10) // Generate 10 recovery codes
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
// Generate 8-character recovery code
|
||||||
|
code := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(code); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to hex string
|
||||||
|
codeStr := fmt.Sprintf("%x", code)
|
||||||
|
codes[i] = codeStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyTOTPWithSecret verifies a TOTP code with given parameters
|
||||||
|
func (u *DefaultUser) verifyTOTPWithSecret(secret, code, algorithm string, digits, period int) bool {
|
||||||
|
// Decode secret
|
||||||
|
secretBytes, err := base32.StdEncoding.DecodeString(secret)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// Check current time window and previous/next windows for clock skew
|
||||||
|
for i := -1; i <= 1; i++ {
|
||||||
|
timeCounter := (now + int64(i*period)) / int64(period)
|
||||||
|
expectedCode := u.generateTOTPCode(secretBytes, timeCounter, algorithm, digits)
|
||||||
|
|
||||||
|
if expectedCode == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTOTPCode generates a TOTP code
|
||||||
|
func (u *DefaultUser) generateTOTPCode(secret []byte, timeCounter int64, algorithm string, digits int) string {
|
||||||
|
// Convert time counter to byte array
|
||||||
|
buf := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(buf, uint64(timeCounter))
|
||||||
|
|
||||||
|
// Choose hash algorithm
|
||||||
|
var h hash.Hash
|
||||||
|
switch algorithm {
|
||||||
|
case "SHA256":
|
||||||
|
h = sha256.New()
|
||||||
|
case "SHA512":
|
||||||
|
h = sha512.New()
|
||||||
|
default:
|
||||||
|
h = sha1.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC
|
||||||
|
for i := 0; i < len(secret); i++ {
|
||||||
|
h.Write([]byte{secret[i] ^ 0x36})
|
||||||
|
}
|
||||||
|
for i := len(secret); i < h.BlockSize(); i++ {
|
||||||
|
h.Write([]byte{0x36})
|
||||||
|
}
|
||||||
|
h.Write(buf)
|
||||||
|
innerHash := h.Sum(nil)
|
||||||
|
|
||||||
|
h.Reset()
|
||||||
|
for i := 0; i < len(secret); i++ {
|
||||||
|
h.Write([]byte{secret[i] ^ 0x5c})
|
||||||
|
}
|
||||||
|
for i := len(secret); i < h.BlockSize(); i++ {
|
||||||
|
h.Write([]byte{0x5c})
|
||||||
|
}
|
||||||
|
h.Write(innerHash)
|
||||||
|
hmacHash := h.Sum(nil)
|
||||||
|
|
||||||
|
// Dynamic truncation
|
||||||
|
offset := hmacHash[len(hmacHash)-1] & 0x0f
|
||||||
|
binCode := binary.BigEndian.Uint32(hmacHash[offset:offset+4]) & 0x7fffffff
|
||||||
|
|
||||||
|
// Generate digits
|
||||||
|
code := binCode % uint32(math.Pow10(digits))
|
||||||
|
|
||||||
|
return fmt.Sprintf("%0*d", digits, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserForAuth retrieves user information for authentication purposes (internal use only)
|
||||||
|
// This method includes sensitive fields like password_hash and should not be exposed to external APIs
|
||||||
|
func (u *DefaultUser) GetUserForAuth(ctx context.Context, identifier string, identifierType string) (interface{}, error) {
|
||||||
|
// Get user from database using the model
|
||||||
|
m := model.Select(u.model)
|
||||||
|
|
||||||
|
var column string
|
||||||
|
switch identifierType {
|
||||||
|
case "username":
|
||||||
|
column = "username"
|
||||||
|
case "email":
|
||||||
|
column = "email"
|
||||||
|
case "subject":
|
||||||
|
column = "subject"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid identifier type: %s", identifierType)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := m.Get(model.QueryParam{
|
||||||
|
Select: AuthUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: column, Value: identifier},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user for auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user) == 0 {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user[0], nil
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package user
|
package removedfuncref
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
70
openapi/oauth/providers/user/role.go
Normal file
70
openapi/oauth/providers/user/role.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role Resource
|
||||||
|
|
||||||
|
// GetRole retrieves role information by role_id
|
||||||
|
func (u *DefaultUser) GetRole(ctx context.Context, roleID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new user role
|
||||||
|
func (u *DefaultUser) CreateRole(ctx context.Context, roleData maps.MapStrAny) (interface{}, error) {
|
||||||
|
// TODO: implement - role_id should be provided in roleData
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates an existing role
|
||||||
|
func (u *DefaultUser) UpdateRole(ctx context.Context, roleID string, roleData maps.MapStrAny) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole soft deletes a role (if not system role)
|
||||||
|
func (u *DefaultUser) DeleteRole(ctx context.Context, roleID string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoles retrieves roles by query parameters
|
||||||
|
func (u *DefaultUser) GetRoles(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateRoles retrieves paginated list of roles
|
||||||
|
func (u *DefaultUser) PaginateRoles(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRoles returns total count of roles with optional filters
|
||||||
|
func (u *DefaultUser) CountRoles(ctx context.Context, param model.QueryParam) (int64, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRolePermissions retrieves permissions for a role
|
||||||
|
func (u *DefaultUser) GetRolePermissions(ctx context.Context, roleID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRolePermissions sets permissions for a role
|
||||||
|
func (u *DefaultUser) SetRolePermissions(ctx context.Context, roleID string, permissions maps.MapStrAny) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRolePermissions validates if role has specific permissions
|
||||||
|
func (u *DefaultUser) ValidateRolePermissions(ctx context.Context, roleID string, requiredPermissions []string) (bool, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
64
openapi/oauth/providers/user/type.go
Normal file
64
openapi/oauth/providers/user/type.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Type Resource
|
||||||
|
|
||||||
|
// GetType retrieves type information by type_id
|
||||||
|
func (u *DefaultUser) GetType(ctx context.Context, typeID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateType creates a new user type
|
||||||
|
func (u *DefaultUser) CreateType(ctx context.Context, typeData maps.MapStrAny) (interface{}, error) {
|
||||||
|
// TODO: implement - type_id should be provided in typeData
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateType updates an existing type
|
||||||
|
func (u *DefaultUser) UpdateType(ctx context.Context, typeID string, typeData maps.MapStrAny) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteType soft deletes a type
|
||||||
|
func (u *DefaultUser) DeleteType(ctx context.Context, typeID string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypes retrieves types by query parameters
|
||||||
|
func (u *DefaultUser) GetTypes(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateTypes retrieves paginated list of types
|
||||||
|
func (u *DefaultUser) PaginateTypes(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTypes returns total count of types with optional filters
|
||||||
|
func (u *DefaultUser) CountTypes(ctx context.Context, param model.QueryParam) (int64, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypeConfiguration retrieves configuration for a type (schema, features, limits, etc.)
|
||||||
|
func (u *DefaultUser) GetTypeConfiguration(ctx context.Context, typeID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTypeConfiguration sets configuration for a type
|
||||||
|
func (u *DefaultUser) SetTypeConfiguration(ctx context.Context, typeID string, config maps.MapStrAny) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
295
openapi/oauth/providers/user/user_basic.go
Normal file
295
openapi/oauth/providers/user/user_basic.go
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User Basic Operations
|
||||||
|
|
||||||
|
// GetUser retrieves user information using the global user_id
|
||||||
|
func (u *DefaultUser) GetUser(ctx context.Context, userID string) (maps.MapStrAny, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: u.publicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToGetUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByPreferredUsername retrieves user by preferred_username (OIDC standard)
|
||||||
|
func (u *DefaultUser) GetUserByPreferredUsername(ctx context.Context, preferredUsername string) (maps.MapStrAny, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: u.publicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "preferred_username", Value: preferredUsername},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToGetUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves user by email address
|
||||||
|
func (u *DefaultUser) GetUserByEmail(ctx context.Context, email string) (maps.MapStrAny, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: u.publicUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "email", Value: email},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToGetUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserForAuth retrieves user information for authentication purposes (internal use only)
|
||||||
|
func (u *DefaultUser) GetUserForAuth(ctx context.Context, identifier string, identifierType string) (maps.MapStrAny, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
|
||||||
|
var column string
|
||||||
|
switch identifierType {
|
||||||
|
case "user_id":
|
||||||
|
column = "user_id"
|
||||||
|
case "preferred_username":
|
||||||
|
column = "preferred_username"
|
||||||
|
case "email":
|
||||||
|
column = "email"
|
||||||
|
case "phone_number":
|
||||||
|
column = "phone_number"
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf(ErrInvalidIdentifierType, identifierType)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: u.authUserFields,
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: column, Value: identifier},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToGetUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword verifies password against password hash (no database query needed)
|
||||||
|
func (u *DefaultUser) VerifyPassword(ctx context.Context, password string, passwordHash string) (bool, error) {
|
||||||
|
if passwordHash == "" {
|
||||||
|
return false, fmt.Errorf(ErrNoPasswordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password using bcrypt (copied from yao/helper/password.go logic)
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // Invalid password, but no error (return false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword updates user password (requires current password verification)
|
||||||
|
func (u *DefaultUser) UpdatePassword(ctx context.Context, userID string, newPassword string) error {
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"password_hash": newPassword, // Yao will auto-hash
|
||||||
|
"password_changed_at": time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
affected, err := m.UpdateWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1, // Safety: ensure only one record is updated
|
||||||
|
}, updateData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToUpdateUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword generates and sets a new random password (admin/recovery operation)
|
||||||
|
func (u *DefaultUser) ResetPassword(ctx context.Context, userID string) (string, error) {
|
||||||
|
// Generate a random password
|
||||||
|
randomPassword, err := generateRandomPassword(12) // 12 characters
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(ErrFailedToGeneratePassword, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"password_hash": randomPassword, // Yao will auto-hash
|
||||||
|
"password_changed_at": time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
affected, err := m.UpdateWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1, // Safety: ensure only one record is updated
|
||||||
|
}, updateData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(ErrFailedToUpdateUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return "", fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomPassword, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user with OIDC standard fields
|
||||||
|
func (u *DefaultUser) CreateUser(ctx context.Context, userData maps.MapStrAny) (interface{}, error) {
|
||||||
|
// Auto-generate user_id if not provided
|
||||||
|
if _, exists := userData["user_id"]; !exists {
|
||||||
|
userID, err := u.GenerateUserID(ctx, true) // Force safe mode to ensure uniqueness
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToGenerateUserID, err)
|
||||||
|
}
|
||||||
|
userData["user_id"] = userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yao Model will auto-hash password if provided as password_hash field
|
||||||
|
if password, ok := userData["password"].(string); ok && password != "" {
|
||||||
|
userData["password_hash"] = password // Let Yao handle the hashing
|
||||||
|
delete(userData, "password") // Remove plain password key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default status if not provided
|
||||||
|
if _, exists := userData["status"]; !exists {
|
||||||
|
userData["status"] = "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
id, err := m.Create(userData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToCreateUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates user information (excludes sensitive fields like password, MFA)
|
||||||
|
func (u *DefaultUser) UpdateUser(ctx context.Context, userID string, userData maps.MapStrAny) error {
|
||||||
|
// Remove sensitive fields that should use dedicated methods
|
||||||
|
sensitiveFields := []string{
|
||||||
|
"password", "password_hash", "password_changed_at",
|
||||||
|
"mfa_secret", "mfa_recovery_hash", "mfa_enabled", "mfa_enabled_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range sensitiveFields {
|
||||||
|
delete(userData, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip update if no valid fields remain
|
||||||
|
if len(userData) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select(u.model)
|
||||||
|
affected, err := m.UpdateWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1, // Safety: ensure only one record is updated
|
||||||
|
}, userData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToUpdateUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser soft deletes a user account
|
||||||
|
func (u *DefaultUser) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
affected, err := m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1, // Safety: ensure only one record is deleted
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToDeleteUser, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf(ErrUserNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserLastLogin updates the user's last login timestamp
|
||||||
|
func (u *DefaultUser) UpdateUserLastLogin(ctx context.Context, userID string) error {
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"last_login_at": time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.UpdateUser(ctx, userID, updateData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserStatus updates user account status (active, disabled, suspended, etc.)
|
||||||
|
func (u *DefaultUser) UpdateUserStatus(ctx context.Context, userID string, status string) error {
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.UpdateUser(ctx, userID, updateData)
|
||||||
|
}
|
||||||
290
openapi/oauth/providers/user/user_basic_test.go
Normal file
290
openapi/oauth/providers/user/user_basic_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package user_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserBasicOperations(t *testing.T) {
|
||||||
|
prepare(t)
|
||||||
|
defer clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create test user data dynamically
|
||||||
|
testUser := &TestUserData{
|
||||||
|
PreferredUsername: "testuser001",
|
||||||
|
Email: "testuser001@example.com",
|
||||||
|
Password: "TestPass123!",
|
||||||
|
Name: "Test User 001",
|
||||||
|
GivenName: "Test",
|
||||||
|
FamilyName: "User",
|
||||||
|
Status: "active",
|
||||||
|
RoleID: "user",
|
||||||
|
TypeID: "regular",
|
||||||
|
EmailVerified: true,
|
||||||
|
Metadata: map[string]interface{}{"source": "test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var testUserID string // Store the auto-generated user_id
|
||||||
|
|
||||||
|
// Test CreateUser
|
||||||
|
t.Run("CreateUser", func(t *testing.T) {
|
||||||
|
userMap := maps.MapStrAny{
|
||||||
|
"preferred_username": testUser.PreferredUsername,
|
||||||
|
"email": testUser.Email,
|
||||||
|
"password": testUser.Password,
|
||||||
|
"name": testUser.Name,
|
||||||
|
"given_name": testUser.GivenName,
|
||||||
|
"family_name": testUser.FamilyName,
|
||||||
|
"status": testUser.Status,
|
||||||
|
"role_id": testUser.RoleID,
|
||||||
|
"type_id": testUser.TypeID,
|
||||||
|
"email_verified": testUser.EmailVerified,
|
||||||
|
"metadata": testUser.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := testProvider.CreateUser(ctx, userMap)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, id)
|
||||||
|
|
||||||
|
// Verify user was created with auto-generated user_id
|
||||||
|
assert.Contains(t, userMap, "user_id")
|
||||||
|
assert.NotEmpty(t, userMap["user_id"])
|
||||||
|
|
||||||
|
// Store generated user_id for subsequent tests
|
||||||
|
testUserID = userMap["user_id"].(string)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test GetUser
|
||||||
|
t.Run("GetUser", func(t *testing.T) {
|
||||||
|
user, err := testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, user)
|
||||||
|
assert.Equal(t, testUser.PreferredUsername, user["preferred_username"])
|
||||||
|
assert.Equal(t, testUser.Email, user["email"])
|
||||||
|
assert.Equal(t, testUser.Name, user["name"])
|
||||||
|
|
||||||
|
// Should not contain password_hash in public fields
|
||||||
|
assert.NotContains(t, user, "password_hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test GetUserByPreferredUsername
|
||||||
|
t.Run("GetUserByPreferredUsername", func(t *testing.T) {
|
||||||
|
user, err := testProvider.GetUserByPreferredUsername(ctx, testUser.PreferredUsername)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, user)
|
||||||
|
assert.Equal(t, testUserID, user["user_id"])
|
||||||
|
assert.Equal(t, testUser.Email, user["email"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test GetUserByEmail
|
||||||
|
t.Run("GetUserByEmail", func(t *testing.T) {
|
||||||
|
user, err := testProvider.GetUserByEmail(ctx, testUser.Email)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, user)
|
||||||
|
assert.Equal(t, testUserID, user["user_id"])
|
||||||
|
assert.Equal(t, testUser.PreferredUsername, user["preferred_username"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test GetUserForAuth
|
||||||
|
t.Run("GetUserForAuth", func(t *testing.T) {
|
||||||
|
user, err := testProvider.GetUserForAuth(ctx, testUserID, "user_id")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, user)
|
||||||
|
assert.Equal(t, testUser.PreferredUsername, user["preferred_username"])
|
||||||
|
|
||||||
|
// Should contain password_hash for auth
|
||||||
|
assert.Contains(t, user, "password_hash")
|
||||||
|
assert.NotEmpty(t, user["password_hash"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test VerifyPassword
|
||||||
|
t.Run("VerifyPassword", func(t *testing.T) {
|
||||||
|
// Get user auth data first
|
||||||
|
user, err := testProvider.GetUserForAuth(ctx, testUserID, "user_id")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
passwordHash := user["password_hash"].(string)
|
||||||
|
|
||||||
|
// Test correct password
|
||||||
|
valid, err := testProvider.VerifyPassword(ctx, testUser.Password, passwordHash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, valid)
|
||||||
|
|
||||||
|
// Test incorrect password
|
||||||
|
valid, err = testProvider.VerifyPassword(ctx, "wrongpassword", passwordHash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, valid)
|
||||||
|
|
||||||
|
// Test empty password hash
|
||||||
|
valid, err = testProvider.VerifyPassword(ctx, testUser.Password, "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.False(t, valid)
|
||||||
|
assert.Contains(t, err.Error(), "no password hash found")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test UpdateUser
|
||||||
|
t.Run("UpdateUser", func(t *testing.T) {
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"name": "Updated Test User",
|
||||||
|
"given_name": "Updated",
|
||||||
|
"family_name": "User",
|
||||||
|
"metadata": map[string]interface{}{"updated": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := testProvider.UpdateUser(ctx, testUserID, updateData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify update
|
||||||
|
user, err := testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Updated Test User", user["name"])
|
||||||
|
assert.Equal(t, "Updated", user["given_name"])
|
||||||
|
|
||||||
|
// Test updating sensitive fields (should be ignored)
|
||||||
|
sensitiveData := maps.MapStrAny{
|
||||||
|
"password": "newpassword",
|
||||||
|
"password_hash": "newhash",
|
||||||
|
"mfa_secret": "newsecret",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = testProvider.UpdateUser(ctx, testUserID, sensitiveData)
|
||||||
|
assert.NoError(t, err) // Should not error, just ignore sensitive fields
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test UpdatePassword
|
||||||
|
t.Run("UpdatePassword", func(t *testing.T) {
|
||||||
|
newPassword := "NewTestPass789!"
|
||||||
|
|
||||||
|
err := testProvider.UpdatePassword(ctx, testUserID, newPassword)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify password was updated
|
||||||
|
user, err := testProvider.GetUserForAuth(ctx, testUserID, "user_id")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
passwordHash := user["password_hash"].(string)
|
||||||
|
valid, err := testProvider.VerifyPassword(ctx, newPassword, passwordHash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, valid)
|
||||||
|
|
||||||
|
// Old password should not work
|
||||||
|
valid, err = testProvider.VerifyPassword(ctx, testUser.Password, passwordHash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test ResetPassword
|
||||||
|
t.Run("ResetPassword", func(t *testing.T) {
|
||||||
|
randomPassword, err := testProvider.ResetPassword(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, randomPassword)
|
||||||
|
assert.Len(t, randomPassword, 12) // Should be 12 characters
|
||||||
|
|
||||||
|
// Verify random password works
|
||||||
|
user, err := testProvider.GetUserForAuth(ctx, testUserID, "user_id")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
passwordHash := user["password_hash"].(string)
|
||||||
|
valid, err := testProvider.VerifyPassword(ctx, randomPassword, passwordHash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, valid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test UpdateUserLastLogin
|
||||||
|
t.Run("UpdateUserLastLogin", func(t *testing.T) {
|
||||||
|
err := testProvider.UpdateUserLastLogin(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify last_login_at was updated
|
||||||
|
user, err := testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, user["last_login_at"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test UpdateUserStatus
|
||||||
|
t.Run("UpdateUserStatus", func(t *testing.T) {
|
||||||
|
err := testProvider.UpdateUserStatus(ctx, testUserID, "suspended")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify status was updated
|
||||||
|
user, err := testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "suspended", user["status"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test DeleteUser (at the end)
|
||||||
|
t.Run("DeleteUser", func(t *testing.T) {
|
||||||
|
err := testProvider.DeleteUser(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify user was deleted
|
||||||
|
_, err = testProvider.GetUser(ctx, testUserID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserErrorHandling(t *testing.T) {
|
||||||
|
prepare(t)
|
||||||
|
defer clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
nonExistentUserID := "non-existent-user-id"
|
||||||
|
|
||||||
|
t.Run("GetUser_NotFound", func(t *testing.T) {
|
||||||
|
_, err := testProvider.GetUser(ctx, nonExistentUserID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetUserByPreferredUsername_NotFound", func(t *testing.T) {
|
||||||
|
_, err := testProvider.GetUserByPreferredUsername(ctx, "nonexistent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetUserByEmail_NotFound", func(t *testing.T) {
|
||||||
|
_, err := testProvider.GetUserByEmail(ctx, "nonexistent@example.com")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetUserForAuth_InvalidIdentifierType", func(t *testing.T) {
|
||||||
|
_, err := testProvider.GetUserForAuth(ctx, "test", "invalid_type")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid identifier type")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateUser_NotFound", func(t *testing.T) {
|
||||||
|
updateData := maps.MapStrAny{"name": "Test"}
|
||||||
|
err := testProvider.UpdateUser(ctx, nonExistentUserID, updateData)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdatePassword_NotFound", func(t *testing.T) {
|
||||||
|
err := testProvider.UpdatePassword(ctx, nonExistentUserID, "newpassword")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ResetPassword_NotFound", func(t *testing.T) {
|
||||||
|
_, err := testProvider.ResetPassword(ctx, nonExistentUserID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DeleteUser_NotFound", func(t *testing.T) {
|
||||||
|
err := testProvider.DeleteUser(ctx, nonExistentUserID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "user not found")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: TestIDGeneration moved to utils_test.go (tests utils.go methods)
|
||||||
|
// NOTE: TestFieldListConfiguration moved to default_test.go (tests configuration, not basic operations)
|
||||||
28
openapi/oauth/providers/user/user_list.go
Normal file
28
openapi/oauth/providers/user/user_list.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User List and Search
|
||||||
|
|
||||||
|
// GetUsers retrieves users by query parameters (compatible with Model.Get)
|
||||||
|
func (u *DefaultUser) GetUsers(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginateUsers retrieves paginated list of users (compatible with Model.Paginate)
|
||||||
|
func (u *DefaultUser) PaginateUsers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUsers returns total count of users with optional filters
|
||||||
|
func (u *DefaultUser) CountUsers(ctx context.Context, param model.QueryParam) (int64, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
57
openapi/oauth/providers/user/user_mfa.go
Normal file
57
openapi/oauth/providers/user/user_mfa.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User MFA Management
|
||||||
|
|
||||||
|
// GenerateMFASecret generates a new TOTP secret for user
|
||||||
|
func (u *DefaultUser) GenerateMFASecret(ctx context.Context, userID string, issuer string, accountName string) (string, string, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableMFA enables multi-factor authentication for user
|
||||||
|
func (u *DefaultUser) EnableMFA(ctx context.Context, userID string, secret string, code string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableMFA disables multi-factor authentication for user
|
||||||
|
func (u *DefaultUser) DisableMFA(ctx context.Context, userID string, code string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMFACode verifies a TOTP code for user
|
||||||
|
func (u *DefaultUser) VerifyMFACode(ctx context.Context, userID string, code string) (bool, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRecoveryCodes generates new recovery codes for user and stores their hash
|
||||||
|
func (u *DefaultUser) GenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRecoveryCode verifies and consumes a recovery code
|
||||||
|
func (u *DefaultUser) VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMFAEnabled checks if MFA is enabled for a user
|
||||||
|
func (u *DefaultUser) IsMFAEnabled(ctx context.Context, userID string) (bool, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMFAConfig retrieves MFA configuration for a user
|
||||||
|
func (u *DefaultUser) GetMFAConfig(ctx context.Context, userID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
39
openapi/oauth/providers/user/user_role_type.go
Normal file
39
openapi/oauth/providers/user/user_role_type.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User Role and Type Management
|
||||||
|
|
||||||
|
// GetUserRole retrieves user's role information
|
||||||
|
func (u *DefaultUser) GetUserRole(ctx context.Context, userID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserRole assigns a role to a user
|
||||||
|
func (u *DefaultUser) SetUserRole(ctx context.Context, userID string, roleID string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserType retrieves user's type information
|
||||||
|
func (u *DefaultUser) GetUserType(ctx context.Context, userID string) (maps.MapStrAny, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserType assigns a type to a user
|
||||||
|
func (u *DefaultUser) SetUserType(ctx context.Context, userID string, typeID string) error {
|
||||||
|
// TODO: implement
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUserScope validates if a user has access to requested scopes based on role and type
|
||||||
|
func (u *DefaultUser) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) {
|
||||||
|
// TODO: implement
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
159
openapi/oauth/providers/user/user_test.go
Normal file
159
openapi/oauth/providers/user/user_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package user_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUserData represents test user data structure (without UserID - auto-generated)
|
||||||
|
type TestUserData struct {
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
GivenName string `json:"given_name"`
|
||||||
|
FamilyName string `json:"family_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RoleID string `json:"role_id"`
|
||||||
|
TypeID string `json:"type_id"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
testProvider *user.DefaultUser
|
||||||
|
)
|
||||||
|
|
||||||
|
// prepare initializes the test environment for each test function.
|
||||||
|
//
|
||||||
|
// PREREQUISITES:
|
||||||
|
// Before running any tests in this package, you MUST execute the following command in your terminal:
|
||||||
|
//
|
||||||
|
// source $YAO_SOURCE_ROOT/env.local.sh
|
||||||
|
//
|
||||||
|
// This loads the required environment variables for the test environment.
|
||||||
|
//
|
||||||
|
// WHAT THIS FUNCTION DOES:
|
||||||
|
// Step 1: Calls test.Prepare(t, config.Conf) to initialize the base Yao test environment
|
||||||
|
//
|
||||||
|
// This sets up database connections, configurations, and other core dependencies
|
||||||
|
//
|
||||||
|
// Step 2: Creates test provider with configured options
|
||||||
|
//
|
||||||
|
// This sets up the DefaultUser provider for testing with test-specific configuration
|
||||||
|
//
|
||||||
|
// Usage pattern for ALL user provider tests:
|
||||||
|
//
|
||||||
|
// func TestYourFunction(t *testing.T) {
|
||||||
|
// prepare(t)
|
||||||
|
// defer clean()
|
||||||
|
//
|
||||||
|
// ctx := context.Background() // Each test creates its own context
|
||||||
|
//
|
||||||
|
// // Your actual test code here...
|
||||||
|
// }
|
||||||
|
func prepare(t *testing.T) {
|
||||||
|
// Step 1: Initialize base test environment with all Yao dependencies
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
|
||||||
|
// Step 2: Initialize test provider
|
||||||
|
testProvider = user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
IDPrefix: "test_",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean cleans up the test environment after each test function.
|
||||||
|
//
|
||||||
|
// WHAT THIS FUNCTION DOES:
|
||||||
|
// Step 1: Clean up test data from database
|
||||||
|
// Step 2: Reset global variables
|
||||||
|
// Step 3: Call test.Clean() to clean up the base test environment
|
||||||
|
//
|
||||||
|
// This function should ALWAYS be called with defer to ensure cleanup happens even if tests panic.
|
||||||
|
func clean() {
|
||||||
|
// Step 1: Clean up test data
|
||||||
|
cleanupTestData()
|
||||||
|
|
||||||
|
// Step 2: Reset global variables
|
||||||
|
testProvider = nil
|
||||||
|
|
||||||
|
// Step 3: Clean up base test environment
|
||||||
|
test.Clean()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTestData removes all test data
|
||||||
|
func cleanupTestData() {
|
||||||
|
if testProvider == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := model.Select("__yao.user")
|
||||||
|
|
||||||
|
// Delete test users by pattern
|
||||||
|
for _, pattern := range []string{"test-user-%", "test_%"} {
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", OP: "like", Value: pattern},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clean by username pattern
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "preferred_username", OP: "like", Value: "testuser%"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTestUser creates a user in database for testing
|
||||||
|
func setupTestUser(t *testing.T, ctx context.Context, userData *TestUserData) (interface{}, string) {
|
||||||
|
userMap := maps.MapStrAny{
|
||||||
|
// user_id will be auto-generated by CreateUser
|
||||||
|
"preferred_username": userData.PreferredUsername,
|
||||||
|
"email": userData.Email,
|
||||||
|
"password": userData.Password, // Will be auto-hashed by Yao
|
||||||
|
"name": userData.Name,
|
||||||
|
"given_name": userData.GivenName,
|
||||||
|
"family_name": userData.FamilyName,
|
||||||
|
"status": userData.Status,
|
||||||
|
"role_id": userData.RoleID,
|
||||||
|
"type_id": userData.TypeID,
|
||||||
|
"email_verified": userData.EmailVerified,
|
||||||
|
"metadata": userData.Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := testProvider.CreateUser(ctx, userMap)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Return both database ID and auto-generated user_id
|
||||||
|
userID := userMap["user_id"].(string)
|
||||||
|
return id, userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestUserData creates test user data with unique identifier
|
||||||
|
func createTestUserData(id string) *TestUserData {
|
||||||
|
return &TestUserData{
|
||||||
|
// user_id will be auto-generated, not set here
|
||||||
|
PreferredUsername: "testuser" + id,
|
||||||
|
Email: "testuser" + id + "@example.com",
|
||||||
|
Password: "TestPass" + id + "!",
|
||||||
|
Name: "Test User " + id,
|
||||||
|
GivenName: "Test",
|
||||||
|
FamilyName: "User " + id,
|
||||||
|
Status: "active",
|
||||||
|
RoleID: "user",
|
||||||
|
TypeID: "regular",
|
||||||
|
EmailVerified: true,
|
||||||
|
Metadata: map[string]interface{}{"source": "test", "id": id},
|
||||||
|
}
|
||||||
|
}
|
||||||
162
openapi/oauth/providers/user/utils.go
Normal file
162
openapi/oauth/providers/user/utils.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jaevor/go-nanoid"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
|
||||||
|
// GenerateUserID generates a new unique user_id for user creation
|
||||||
|
// safe: optional parameter, if true check for collisions and retry if needed
|
||||||
|
//
|
||||||
|
// defaults to true for NanoID, false for UUID
|
||||||
|
func (u *DefaultUser) GenerateUserID(ctx context.Context, safe ...bool) (string, error) {
|
||||||
|
// Determine safe mode: default based on strategy, or use provided value
|
||||||
|
var safeMode bool
|
||||||
|
if len(safe) > 0 {
|
||||||
|
safeMode = safe[0] // Use provided value
|
||||||
|
} else {
|
||||||
|
// Default: safe for NanoID, unsafe for UUID
|
||||||
|
safeMode = u.idStrategy == NanoIDStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
if !safeMode {
|
||||||
|
// Direct generation without collision detection (UUID case)
|
||||||
|
return u.generateUserID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe generation with collision detection (NanoID case)
|
||||||
|
const maxRetries = 10 // Prevent infinite loops
|
||||||
|
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
// Generate new ID
|
||||||
|
id, err := u.generateUserID()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(ErrFailedToGenerateUserID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ID already exists
|
||||||
|
exists, err := u.userIDExists(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to check user_id existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return id, nil // Found unique ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID exists, retry with new generation
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to generate unique user_id after %d retries", maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUserID generates a new user_id based on configured strategy (internal use)
|
||||||
|
func (u *DefaultUser) generateUserID() (string, error) {
|
||||||
|
var id string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch u.idStrategy {
|
||||||
|
case UUIDStrategy:
|
||||||
|
id, err = generateUUID()
|
||||||
|
case NanoIDStrategy:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
id, err = generateNanoID(12) // 12 characters, URL-safe, readable
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add prefix if configured
|
||||||
|
if u.idPrefix != "" {
|
||||||
|
return u.idPrefix + id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDExists checks if a user_id already exists in the database
|
||||||
|
func (u *DefaultUser) userIDExists(ctx context.Context, userID string) (bool, error) {
|
||||||
|
m := model.Select(u.model)
|
||||||
|
users, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"id"}, // Just get primary key, minimal data
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "user_id", Value: userID},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(users) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOAuthUserID quickly retrieves user_id by OAuth provider and subject
|
||||||
|
func (u *DefaultUser) GetOAuthUserID(ctx context.Context, provider string, subject string) (string, error) {
|
||||||
|
m := model.Select(u.oauthAccountModel)
|
||||||
|
accounts, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"user_id"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "provider", Value: provider},
|
||||||
|
{Column: "sub", Value: subject},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(ErrFailedToGetOAuthAccount, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
return "", fmt.Errorf(ErrOAuthAccountNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := accounts[0]["user_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf(ErrInvalidUserIDInOAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateNanoID generates a Nano ID using the library
|
||||||
|
func generateNanoID(length int) (string, error) {
|
||||||
|
// URL-safe alphabet (no ambiguous characters like 0/O, 1/l/I)
|
||||||
|
const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
|
||||||
|
nanoidGen, err := nanoid.CustomASCII(alphabet, length)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return nanoidGen(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUUID generates a traditional UUID using Google's library
|
||||||
|
func generateUUID() (string, error) {
|
||||||
|
return uuid.NewString(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomPassword generates a random password with specified length
|
||||||
|
func generateRandomPassword(length int) (string, error) {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, b := range bytes {
|
||||||
|
bytes[i] = charset[b%byte(len(charset))]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
356
openapi/oauth/providers/user/utils_test.go
Normal file
356
openapi/oauth/providers/user/utils_test.go
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
package user_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateUserID(t *testing.T) {
|
||||||
|
prepare(t)
|
||||||
|
defer clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("NanoID_Strategy_Safe_Mode", func(t *testing.T) {
|
||||||
|
// Test NanoID strategy with safe mode (default)
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
IDPrefix: "user_",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(ctx, true)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.True(t, strings.HasPrefix(userID, "user_"))
|
||||||
|
assert.Greater(t, len(userID), 5) // Should be "user_" + at least some characters
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NanoID_Strategy_Unsafe_Mode", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
IDPrefix: "test_",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(ctx, false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.True(t, strings.HasPrefix(userID, "test_"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UUID_Strategy_Safe_Mode", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
IDPrefix: "uuid_",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(ctx, true)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.True(t, strings.HasPrefix(userID, "uuid_"))
|
||||||
|
// UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars) + prefix
|
||||||
|
assert.Greater(t, len(userID), 40)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UUID_Strategy_Unsafe_Mode", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
IDPrefix: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(ctx, false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.Len(t, userID, 36) // Standard UUID length without prefix
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Default_Safe_Mode_Behavior", func(t *testing.T) {
|
||||||
|
// NanoID should default to safe mode
|
||||||
|
providerNano := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := providerNano.GenerateUserID(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
|
||||||
|
// UUID should default to unsafe mode
|
||||||
|
providerUUID := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID2, err := providerUUID.GenerateUserID(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID2)
|
||||||
|
assert.Len(t, userID2, 36) // Standard UUID length
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Collision_Detection", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
IDPrefix: "collision_",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate first ID and create user with it
|
||||||
|
userID1, err := provider.GenerateUserID(ctx, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
userData := maps.MapStrAny{
|
||||||
|
"user_id": userID1,
|
||||||
|
"preferred_username": "collisiontest",
|
||||||
|
"email": "collision@test.com",
|
||||||
|
"password": "TestPass123!",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
_, err = provider.CreateUser(ctx, userData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Generate second ID - should be different due to collision detection
|
||||||
|
userID2, err := provider.GenerateUserID(ctx, true)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEqual(t, userID1, userID2)
|
||||||
|
assert.True(t, strings.HasPrefix(userID2, "collision_"))
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
provider.DeleteUser(ctx, userID1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Multiple_IDs_Uniqueness", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
userID, err := provider.GenerateUserID(ctx, false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
assert.False(t, ids[userID], "Generated duplicate ID: %s", userID)
|
||||||
|
ids[userID] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: TestGetOAuthUserID - depends on CreateOAuthAccount implementation
|
||||||
|
// func TestGetOAuthUserID(t *testing.T) {
|
||||||
|
// // Will be implemented after CreateOAuthAccount is implemented
|
||||||
|
// }
|
||||||
|
|
||||||
|
func TestNanoIDGeneration(t *testing.T) {
|
||||||
|
t.Run("NanoID_Length_And_Characters", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate multiple NanoIDs and check their properties
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
|
||||||
|
// NanoID should be 12 characters (default length)
|
||||||
|
assert.Len(t, userID, 12)
|
||||||
|
|
||||||
|
// Should only contain allowed characters (no ambiguous chars like 0, O, 1, l, I)
|
||||||
|
allowedChars := "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
|
||||||
|
for _, char := range userID {
|
||||||
|
assert.Contains(t, allowedChars, string(char), "Invalid character in NanoID: %c", char)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not contain ambiguous characters
|
||||||
|
forbiddenChars := "01OIl"
|
||||||
|
for _, char := range forbiddenChars {
|
||||||
|
assert.NotContains(t, userID, string(char), "NanoID contains ambiguous character: %c", char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NanoID_With_Prefix", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
IDPrefix: "nano_",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, strings.HasPrefix(userID, "nano_"))
|
||||||
|
assert.Len(t, userID, 17) // "nano_" (5) + 12 chars
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUUIDGeneration(t *testing.T) {
|
||||||
|
t.Run("UUID_Format", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate multiple UUIDs and check their format
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
|
||||||
|
// UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||||
|
assert.Len(t, userID, 36)
|
||||||
|
assert.Equal(t, byte('-'), userID[8])
|
||||||
|
assert.Equal(t, byte('-'), userID[13])
|
||||||
|
assert.Equal(t, byte('-'), userID[18])
|
||||||
|
assert.Equal(t, byte('-'), userID[23])
|
||||||
|
|
||||||
|
// Should be version 4 UUID (14th character should be '4')
|
||||||
|
assert.Equal(t, byte('4'), userID[14])
|
||||||
|
|
||||||
|
// 19th character should be one of '8', '9', 'a', 'b' (variant bits)
|
||||||
|
variant := userID[19]
|
||||||
|
assert.True(t, variant == '8' || variant == '9' || variant == 'a' || variant == 'b',
|
||||||
|
"Invalid UUID variant: %c", variant)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UUID_With_Prefix", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
IDPrefix: "uuid_",
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, strings.HasPrefix(userID, "uuid_"))
|
||||||
|
assert.Len(t, userID, 41) // "uuid_" (5) + 36 chars
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomPasswordGeneration(t *testing.T) {
|
||||||
|
prepare(t)
|
||||||
|
defer clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("ResetPassword_Generates_Random_Password", func(t *testing.T) {
|
||||||
|
// Create test user
|
||||||
|
testUserData := createTestUserData("password")
|
||||||
|
_, testUserID := setupTestUser(t, ctx, testUserData)
|
||||||
|
|
||||||
|
// Reset password should generate a random 12-character password
|
||||||
|
randomPassword, err := testProvider.ResetPassword(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, randomPassword)
|
||||||
|
assert.Len(t, randomPassword, 12)
|
||||||
|
|
||||||
|
// Password should contain mix of characters
|
||||||
|
hasUpper := false
|
||||||
|
hasLower := false
|
||||||
|
hasDigit := false
|
||||||
|
hasSpecial := false
|
||||||
|
|
||||||
|
for _, char := range randomPassword {
|
||||||
|
switch {
|
||||||
|
case char >= 'A' && char <= 'Z':
|
||||||
|
hasUpper = true
|
||||||
|
case char >= 'a' && char <= 'z':
|
||||||
|
hasLower = true
|
||||||
|
case char >= '0' && char <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
case strings.ContainsRune("!@#$%^&*", char):
|
||||||
|
hasSpecial = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least some variety (not enforcing all types for 12 chars)
|
||||||
|
varietyCount := 0
|
||||||
|
if hasUpper {
|
||||||
|
varietyCount++
|
||||||
|
}
|
||||||
|
if hasLower {
|
||||||
|
varietyCount++
|
||||||
|
}
|
||||||
|
if hasDigit {
|
||||||
|
varietyCount++
|
||||||
|
}
|
||||||
|
if hasSpecial {
|
||||||
|
varietyCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Greater(t, varietyCount, 1, "Password should have variety in character types")
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
testProvider.DeleteUser(ctx, testUserID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Multiple_Random_Passwords_Are_Different", func(t *testing.T) {
|
||||||
|
// Create test user
|
||||||
|
testUserData := createTestUserData("multipass")
|
||||||
|
_, testUserID := setupTestUser(t, ctx, testUserData)
|
||||||
|
|
||||||
|
passwords := make(map[string]bool)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
randomPassword, err := testProvider.ResetPassword(ctx, testUserID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, randomPassword)
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
assert.False(t, passwords[randomPassword], "Generated duplicate password: %s", randomPassword)
|
||||||
|
passwords[randomPassword] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
testProvider.DeleteUser(ctx, testUserID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDStrategyConfiguration(t *testing.T) {
|
||||||
|
t.Run("Default_Strategy_Is_NanoID", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
// No IDStrategy specified, should default to NanoID
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.Len(t, userID, 12) // NanoID length
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Explicit_NanoID_Strategy", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.NanoIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.Len(t, userID, 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Explicit_UUID_Strategy", func(t *testing.T) {
|
||||||
|
provider := user.NewDefaultUser(&user.DefaultUserOptions{
|
||||||
|
Prefix: "test:",
|
||||||
|
IDStrategy: user.UUIDStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
userID, err := provider.GenerateUserID(context.Background(), false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, userID)
|
||||||
|
assert.Len(t, userID, 36)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuth interface defines the complete OAuth 2.1 and MCP authorization server functionality
|
// OAuth interface defines the complete OAuth 2.1 and MCP authorization server functionality
|
||||||
|
|
@ -142,65 +144,106 @@ type OAuth interface {
|
||||||
Guard(c *gin.Context)
|
Guard(c *gin.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserProvider interface for user information retrieval
|
// UserProvider interface for user information retrieval and management
|
||||||
type UserProvider interface {
|
type UserProvider interface {
|
||||||
// GetUserByAccessToken retrieves user information using an access token
|
// ============================================================================
|
||||||
GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error)
|
// User Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
// GetUserBySubject retrieves user information using a subject identifier
|
// User Basic Operations
|
||||||
GetUserBySubject(ctx context.Context, subject string) (interface{}, error)
|
GetUser(ctx context.Context, userID string) (maps.MapStrAny, error)
|
||||||
|
|
||||||
// ValidateUserScope validates if a user has access to requested scopes
|
GetUserByPreferredUsername(ctx context.Context, preferredUsername string) (maps.MapStrAny, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (maps.MapStrAny, error)
|
||||||
|
GetUserForAuth(ctx context.Context, identifier string, identifierType string) (maps.MapStrAny, error)
|
||||||
|
VerifyPassword(ctx context.Context, password string, passwordHash string) (bool, error)
|
||||||
|
UpdatePassword(ctx context.Context, userID string, newPassword string) error
|
||||||
|
ResetPassword(ctx context.Context, userID string) (string, error)
|
||||||
|
|
||||||
|
CreateUser(ctx context.Context, userData maps.MapStrAny) (interface{}, error)
|
||||||
|
UpdateUser(ctx context.Context, userID string, userData maps.MapStrAny) error
|
||||||
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
|
UpdateUserLastLogin(ctx context.Context, userID string) error
|
||||||
|
UpdateUserStatus(ctx context.Context, userID string, status string) error
|
||||||
|
|
||||||
|
// User List and Search
|
||||||
|
GetUsers(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error)
|
||||||
|
PaginateUsers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||||
|
CountUsers(ctx context.Context, param model.QueryParam) (int64, error)
|
||||||
|
|
||||||
|
// User Role and Type Management
|
||||||
|
GetUserRole(ctx context.Context, userID string) (maps.MapStrAny, error)
|
||||||
|
SetUserRole(ctx context.Context, userID string, roleID string) error
|
||||||
|
GetUserType(ctx context.Context, userID string) (maps.MapStrAny, error)
|
||||||
|
SetUserType(ctx context.Context, userID string, typeID string) error
|
||||||
ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error)
|
ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error)
|
||||||
|
|
||||||
// Token management methods
|
// User MFA Management
|
||||||
// StoreToken stores a token with expiration time
|
GenerateMFASecret(ctx context.Context, userID string, issuer string, accountName string) (string, string, error)
|
||||||
// StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error
|
EnableMFA(ctx context.Context, userID string, secret string, code string) error
|
||||||
|
DisableMFA(ctx context.Context, userID string, code string) error
|
||||||
// RevokeToken revokes a token by removing it from storage
|
VerifyMFACode(ctx context.Context, userID string, code string) (bool, error)
|
||||||
// RevokeToken(accessToken string) error
|
|
||||||
|
|
||||||
// TokenExists checks if a token exists in storage
|
|
||||||
// TokenExists(accessToken string) bool
|
|
||||||
|
|
||||||
// GetTokenData retrieves token data from storage
|
|
||||||
// GetTokenData(accessToken string) (map[string]interface{}, error)
|
|
||||||
|
|
||||||
// User management methods
|
|
||||||
// CreateUser creates a new user in the database
|
|
||||||
CreateUser(userData map[string]interface{}) (interface{}, error)
|
|
||||||
|
|
||||||
// UpdateUserLastLogin updates the user's last login timestamp
|
|
||||||
UpdateUserLastLogin(userID interface{}) error
|
|
||||||
|
|
||||||
// GetUserByUsername retrieves user by username
|
|
||||||
GetUserByUsername(username string) (interface{}, error)
|
|
||||||
|
|
||||||
// GetUserByEmail retrieves user by email
|
|
||||||
GetUserByEmail(email string) (interface{}, error)
|
|
||||||
|
|
||||||
// GetUserForAuth retrieves user information for authentication purposes (internal use only)
|
|
||||||
// This method includes sensitive fields like password_hash and should not be exposed to external APIs
|
|
||||||
GetUserForAuth(ctx context.Context, identifier string, identifierType string) (interface{}, error)
|
|
||||||
|
|
||||||
// Two-factor authentication methods
|
|
||||||
// GenerateTOTPSecret generates a new TOTP secret for user
|
|
||||||
GenerateTOTPSecret(ctx context.Context, userID string, issuer string, accountName string) (string, string, error) // returns secret and QR code URL
|
|
||||||
|
|
||||||
// EnableTwoFactor enables two-factor authentication for user
|
|
||||||
EnableTwoFactor(ctx context.Context, userID string, secret string, code string) error
|
|
||||||
|
|
||||||
// DisableTwoFactor disables two-factor authentication for user
|
|
||||||
DisableTwoFactor(ctx context.Context, userID string, code string) error
|
|
||||||
|
|
||||||
// VerifyTOTPCode verifies a TOTP code for user
|
|
||||||
VerifyTOTPCode(ctx context.Context, userID string, code string) (bool, error)
|
|
||||||
|
|
||||||
// GenerateRecoveryCodes generates new recovery codes for user
|
|
||||||
GenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error)
|
GenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error)
|
||||||
|
|
||||||
// VerifyRecoveryCode verifies and consumes a recovery code
|
|
||||||
VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error)
|
VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error)
|
||||||
|
IsMFAEnabled(ctx context.Context, userID string) (bool, error)
|
||||||
|
GetMFAConfig(ctx context.Context, userID string) (maps.MapStrAny, error)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OAuth Account Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
CreateOAuthAccount(ctx context.Context, userID string, oauthData maps.MapStrAny) (interface{}, error)
|
||||||
|
GetOAuthAccount(ctx context.Context, provider string, subject string) (maps.MapStrAny, error)
|
||||||
|
GetUserOAuthAccounts(ctx context.Context, userID string) ([]maps.MapStrAny, error)
|
||||||
|
UpdateOAuthAccount(ctx context.Context, provider string, subject string, oauthData maps.MapStrAny) error
|
||||||
|
DeleteOAuthAccount(ctx context.Context, provider string, subject string) error
|
||||||
|
|
||||||
|
GetOAuthAccounts(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error)
|
||||||
|
PaginateOAuthAccounts(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||||
|
CountOAuthAccounts(ctx context.Context, param model.QueryParam) (int64, error)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Role Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
GetRole(ctx context.Context, roleID string) (maps.MapStrAny, error)
|
||||||
|
CreateRole(ctx context.Context, roleData maps.MapStrAny) (interface{}, error)
|
||||||
|
UpdateRole(ctx context.Context, roleID string, roleData maps.MapStrAny) error
|
||||||
|
DeleteRole(ctx context.Context, roleID string) error
|
||||||
|
|
||||||
|
GetRoles(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error)
|
||||||
|
PaginateRoles(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||||
|
CountRoles(ctx context.Context, param model.QueryParam) (int64, error)
|
||||||
|
|
||||||
|
GetRolePermissions(ctx context.Context, roleID string) (maps.MapStrAny, error)
|
||||||
|
SetRolePermissions(ctx context.Context, roleID string, permissions maps.MapStrAny) error
|
||||||
|
ValidateRolePermissions(ctx context.Context, roleID string, requiredPermissions []string) (bool, error)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Type Resource
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
GetType(ctx context.Context, typeID string) (maps.MapStrAny, error)
|
||||||
|
CreateType(ctx context.Context, typeData maps.MapStrAny) (interface{}, error)
|
||||||
|
UpdateType(ctx context.Context, typeID string, typeData maps.MapStrAny) error
|
||||||
|
DeleteType(ctx context.Context, typeID string) error
|
||||||
|
|
||||||
|
GetTypes(ctx context.Context, param model.QueryParam) ([]maps.MapStr, error)
|
||||||
|
PaginateTypes(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||||
|
CountTypes(ctx context.Context, param model.QueryParam) (int64, error)
|
||||||
|
|
||||||
|
GetTypeConfiguration(ctx context.Context, typeID string) (maps.MapStrAny, error)
|
||||||
|
SetTypeConfiguration(ctx context.Context, typeID string, config maps.MapStrAny) error
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utils
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GenerateUserID generates a new unique user_id for user creation
|
||||||
|
GenerateUserID(ctx context.Context, safe ...bool) (string, error)
|
||||||
|
|
||||||
|
// GetOAuthUserID quickly retrieves user_id by OAuth provider and subject
|
||||||
|
GetOAuthUserID(ctx context.Context, provider string, subject string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientProvider interface for OAuth client management and persistence
|
// ClientProvider interface for OAuth client management and persistence
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,54 @@ const (
|
||||||
TokenEndpointAuthSelfSignedTLS = "self_signed_tls_client_auth"
|
TokenEndpointAuthSelfSignedTLS = "self_signed_tls_client_auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// User Status Constants
|
||||||
|
const (
|
||||||
|
UserStatusPending = "pending"
|
||||||
|
UserStatusActive = "active"
|
||||||
|
UserStatusDisabled = "disabled"
|
||||||
|
UserStatusSuspended = "suspended"
|
||||||
|
UserStatusLocked = "locked"
|
||||||
|
UserStatusPasswordExpired = "password_expired"
|
||||||
|
UserStatusEmailUnverified = "email_unverified"
|
||||||
|
UserStatusArchived = "archived"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MFA Algorithm Constants
|
||||||
|
const (
|
||||||
|
MFAAlgorithmSHA1 = "SHA1"
|
||||||
|
MFAAlgorithmSHA256 = "SHA256"
|
||||||
|
MFAAlgorithmSHA512 = "SHA512"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuth Provider Constants
|
||||||
|
const (
|
||||||
|
ProviderLocal = "local"
|
||||||
|
ProviderGoogle = "google"
|
||||||
|
ProviderApple = "apple"
|
||||||
|
ProviderGitHub = "github"
|
||||||
|
ProviderMicrosoft = "microsoft"
|
||||||
|
ProviderWeChat = "wechat"
|
||||||
|
ProviderGeneric = "generic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User Identifier Types
|
||||||
|
const (
|
||||||
|
IdentifierTypeUserID = "user_id"
|
||||||
|
IdentifierTypeSubject = "subject"
|
||||||
|
IdentifierTypePreferredUsername = "preferred_username"
|
||||||
|
IdentifierTypeEmail = "email"
|
||||||
|
IdentifierTypePhoneNumber = "phone_number"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login Methods
|
||||||
|
const (
|
||||||
|
LoginMethodPassword = "password"
|
||||||
|
LoginMethodOAuth = "oauth"
|
||||||
|
LoginMethodMFA = "mfa"
|
||||||
|
LoginMethodRecovery = "recovery"
|
||||||
|
LoginMethodSSO = "sso"
|
||||||
|
)
|
||||||
|
|
||||||
// Response Modes
|
// Response Modes
|
||||||
const (
|
const (
|
||||||
ResponseModeQuery = "query"
|
ResponseModeQuery = "query"
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ import (
|
||||||
|
|
||||||
// UserInfo returns user information for a given access token
|
// UserInfo returns user information for a given access token
|
||||||
func (s *Service) UserInfo(ctx context.Context, accessToken string) (interface{}, error) {
|
func (s *Service) UserInfo(ctx context.Context, accessToken string) (interface{}, error) {
|
||||||
return s.userProvider.GetUserByAccessToken(ctx, accessToken)
|
return s.userProvider.GetUser(ctx, accessToken)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue