Add user model integration and enhance OAuth user provider

- Introduced a new user model `yao/models/user.mod.yao` to support user management functionalities.
- Updated the OAuth service to utilize the new `DefaultUser` provider, enhancing user authentication and management capabilities.
- Refactored user retrieval methods to align with the new user model structure, ensuring compatibility and improved functionality.
- Added token management methods to the user provider interface, streamlining token handling processes.
- Enhanced test utilities to include the new user model for comprehensive testing coverage.
This commit is contained in:
Max 2025-07-18 11:31:39 +08:00
parent e19aa4b8df
commit fb20eb54c5
9 changed files with 2414 additions and 157 deletions

File diff suppressed because one or more lines are too long

View file

@ -28,6 +28,7 @@ var systemModels = map[string]string{
"__yao.dsl": "yao/models/dsl.mod.yao",
"__yao.history": "yao/models/history.mod.yao",
"__yao.kb": "yao/models/kb.mod.yao",
"__yao.user": "yao/models/user.mod.yao",
}
// Load load models

View file

@ -1,12 +1,14 @@
package oauth
import (
"fmt"
"time"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/openapi/oauth/providers/client"
"github.com/yaoapp/yao/openapi/oauth/providers/user"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/share"
)
// Service OAuth service
@ -98,7 +100,12 @@ func NewService(config *Config) (*Service, error) {
// Use UserProvider from config, or create a default one if not provided
userProvider := config.UserProvider
if userProvider == nil {
userProvider = user.NewDefaultUserProvider(nil, nil, nil)
userProvider = user.NewDefaultUser(&user.DefaultUserOptions{
Prefix: fmt.Sprintf("%s:", share.App.Prefix),
Model: "__yao.user",
Cache: config.Cache,
TokenStore: config.Store,
})
}
// Use ClientProvider from config, or create a default one if not provided
@ -106,7 +113,7 @@ func NewService(config *Config) (*Service, error) {
if clientProvider == nil {
var err error
clientProvider, err = client.NewDefaultClient(&client.DefaultClientOptions{
Prefix: "__yao:",
Prefix: fmt.Sprintf("%s:", share.App.Prefix),
Store: config.Store,
Cache: config.Cache,
})

View file

@ -2,51 +2,863 @@ package user
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/yao/openapi/oauth/types"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/gou/store"
)
// DefaultUserProvider provides a default implementation of UserProvider
type DefaultUserProvider struct {
getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error)
getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error)
validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error)
// 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
}
// NewDefaultUserProvider creates a new DefaultUserProvider with the given functions
func NewDefaultUserProvider(
getUserByAccessTokenFunc func(ctx context.Context, accessToken string) (interface{}, error),
getUserBySubjectFunc func(ctx context.Context, subject string) (interface{}, error),
validateUserScopeFunc func(ctx context.Context, userID string, scopes []string) (bool, error),
) *DefaultUserProvider {
return &DefaultUserProvider{
getUserByAccessTokenFunc: getUserByAccessTokenFunc,
getUserBySubjectFunc: getUserBySubjectFunc,
validateUserScopeFunc: validateUserScopeFunc,
// 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 (p *DefaultUserProvider) GetUserByAccessToken(ctx context.Context, accessToken string) (interface{}, error) {
if p.getUserByAccessTokenFunc == nil {
return nil, &types.ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserByAccessToken is not implemented"}
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")
}
return p.getUserByAccessTokenFunc(ctx, accessToken)
// 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 (p *DefaultUserProvider) GetUserBySubject(ctx context.Context, subject string) (interface{}, error) {
if p.getUserBySubjectFunc == nil {
return nil, &types.ErrorResponse{Code: "not_implemented", ErrorDescription: "GetUserBySubject is not implemented"}
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
}
}
return p.getUserBySubjectFunc(ctx, subject)
// 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 (p *DefaultUserProvider) ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error) {
if p.validateUserScopeFunc == nil {
// Default implementation: allow all scopes
return true, nil
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
}
}
return p.validateUserScopeFunc(ctx, userID, scopes)
// 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
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ package types
import (
"context"
"time"
)
// OAuth interface defines the complete OAuth 2.1 and MCP authorization server functionality
@ -145,6 +146,55 @@ type UserProvider interface {
// ValidateUserScope validates if a user has access to requested scopes
ValidateUserScope(ctx context.Context, userID string, scopes []string) (bool, error)
// Token management methods
// StoreToken stores a token with expiration time
StoreToken(accessToken string, tokenData map[string]interface{}, expiration time.Duration) error
// RevokeToken revokes a token by removing it from storage
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)
// VerifyRecoveryCode verifies and consumes a recovery code
VerifyRecoveryCode(ctx context.Context, userID string, code string) (bool, error)
}
// ClientProvider interface for OAuth client management and persistence

View file

@ -162,41 +162,6 @@ type DeviceAuthorizationResponse struct {
Interval int `json:"interval,omitempty"`
}
// UserInfo represents user information from userinfo endpoint
type UserInfo struct {
Subject string `json:"sub"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
Nickname string `json:"nickname,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Profile string `json:"profile,omitempty"`
Picture string `json:"picture,omitempty"`
Website string `json:"website,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
Gender string `json:"gender,omitempty"`
Birthdate string `json:"birthdate,omitempty"`
Zoneinfo string `json:"zoneinfo,omitempty"`
Locale string `json:"locale,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
PhoneVerified bool `json:"phone_number_verified,omitempty"`
Address *UserAddress `json:"address,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
CustomClaims map[string]interface{} `json:"-"`
}
// UserAddress represents user address information
type UserAddress struct {
Formatted string `json:"formatted,omitempty"`
StreetAddress string `json:"street_address,omitempty"`
Locality string `json:"locality,omitempty"`
Region string `json:"region,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
}
// ClientInfo represents OAuth client information
type ClientInfo struct {
ClientID string `json:"client_id"`

View file

@ -43,6 +43,7 @@ var testSystemModels = map[string]string{
"__yao.dsl": "yao/models/dsl.mod.yao",
"__yao.history": "yao/models/history.mod.yao",
"__yao.kb": "yao/models/kb.mod.yao",
"__yao.user": "yao/models/user.mod.yao",
}
// loadSystemModels load system models for testing

267
yao/models/user.mod.yao Normal file
View file

@ -0,0 +1,267 @@
{
"name": "OAuth User",
"label": "OAuth User",
"description": "OAuth user model for authentication and authorization",
"tags": ["oauth", "auth", "user"],
"table": {
"name": "oauth_users",
"comment": "OAuth users table for authentication and authorization"
},
"columns": [
{
"name": "id",
"type": "ID",
"label": "ID",
"comment": "Primary key identifier",
"primary": true
},
{
"name": "subject",
"type": "string",
"label": "Subject",
"comment": "OAuth subject identifier (sub claim)",
"length": 255,
"nullable": true,
"unique": true,
"index": true
},
{
"name": "username",
"type": "string",
"label": "Username",
"comment": "User login username",
"length": 100,
"nullable": true,
"unique": true,
"index": true
},
{
"name": "email",
"type": "string",
"label": "Email",
"comment": "User email address",
"length": 255,
"nullable": true,
"unique": true,
"index": true
},
{
"name": "password_hash",
"type": "string",
"label": "Password Hash",
"comment": "Hashed password for authentication",
"length": 255,
"nullable": true,
"crypt": "PASSWORD"
},
{
"name": "first_name",
"type": "string",
"label": "First Name",
"comment": "User first name",
"length": 100,
"nullable": true
},
{
"name": "last_name",
"type": "string",
"label": "Last Name",
"comment": "User last name",
"length": 100,
"nullable": true
},
{
"name": "full_name",
"type": "string",
"label": "Full Name",
"comment": "User full display name",
"length": 200,
"nullable": true
},
{
"name": "avatar_url",
"type": "string",
"label": "Avatar URL",
"comment": "URL to user profile picture",
"length": 500,
"nullable": true
},
{
"name": "mobile",
"type": "string",
"label": "Mobile",
"comment": "User mobile phone number",
"length": 50,
"nullable": true,
"index": true
},
{
"name": "address",
"type": "text",
"label": "Address",
"comment": "User address information",
"nullable": true
},
{
"name": "scopes",
"type": "json",
"label": "Scopes",
"comment": "Available OAuth scopes for this user",
"nullable": true
},
{
"name": "status",
"type": "enum",
"label": "Status",
"comment": "User account status",
"option": ["active", "inactive", "suspended", "pending"],
"default": "pending",
"index": true,
"nullable": false
},
{
"name": "email_verified",
"type": "boolean",
"label": "Email Verified",
"comment": "Whether user email is verified",
"default": false,
"index": true
},
{
"name": "mobile_verified",
"type": "boolean",
"label": "Mobile Verified",
"comment": "Whether user mobile phone is verified",
"default": false,
"index": true
},
{
"name": "two_factor_enabled",
"type": "boolean",
"label": "Two Factor Enabled",
"comment": "Whether two-factor authentication is enabled",
"default": false,
"index": true
},
{
"name": "two_factor_secret",
"type": "string",
"label": "Two Factor Secret",
"comment": "TOTP shared secret key (Base32 encoded)",
"length": 255,
"nullable": true,
"crypt": "AES"
},
{
"name": "two_factor_issuer",
"type": "string",
"label": "Two Factor Issuer",
"comment": "Issuer name displayed in authenticator app",
"length": 100,
"nullable": true,
"default": "YAO OAuth"
},
{
"name": "two_factor_algorithm",
"type": "enum",
"label": "Two Factor Algorithm",
"comment": "TOTP algorithm (SHA1, SHA256, SHA512)",
"option": ["SHA1", "SHA256", "SHA512"],
"default": "SHA1",
"nullable": true
},
{
"name": "two_factor_digits",
"type": "integer",
"label": "Two Factor Digits",
"comment": "Number of digits in TOTP code (6 or 8)",
"default": 6,
"nullable": true
},
{
"name": "two_factor_period",
"type": "integer",
"label": "Two Factor Period",
"comment": "TOTP time period in seconds (usually 30)",
"default": 30,
"nullable": true
},
{
"name": "two_factor_account_name",
"type": "string",
"label": "Two Factor Account Name",
"comment": "Account name displayed in authenticator app (usually username or email)",
"length": 255,
"nullable": true
},
{
"name": "two_factor_recovery_codes",
"type": "json",
"label": "Two Factor Recovery Codes",
"comment": "Backup recovery codes for two-factor authentication",
"nullable": true
},
{
"name": "two_factor_enabled_at",
"type": "timestamp",
"label": "Two Factor Enabled At",
"comment": "When two-factor authentication was enabled",
"nullable": true,
"index": true
},
{
"name": "two_factor_last_verified_at",
"type": "timestamp",
"label": "Two Factor Last Verified At",
"comment": "Last time two-factor authentication was verified",
"nullable": true,
"index": true
},
{
"name": "last_login_at",
"type": "timestamp",
"label": "Last Login At",
"comment": "Last login timestamp",
"nullable": true,
"index": true
},
{
"name": "password_changed_at",
"type": "timestamp",
"label": "Password Changed At",
"comment": "When password was last changed",
"nullable": true
},
{
"name": "metadata",
"type": "json",
"label": "Metadata",
"comment": "Additional user metadata and custom fields",
"nullable": true
},
{
"name": "preferences",
"type": "json",
"label": "Preferences",
"comment": "User preferences and settings",
"nullable": true
}
],
"indexes": [
{
"name": "idx_user_two_factor",
"columns": ["two_factor_enabled", "two_factor_enabled_at"],
"type": "index",
"comment": "Index on two-factor authentication status and time"
},
{
"name": "idx_user_verification",
"columns": ["email_verified", "mobile_verified"],
"type": "index",
"comment": "Index on verification status for filtering"
}
],
"relations": {},
"values": [],
"option": { "timestamps": true, "soft_deletes": true }
}