보안: - Zip Bomb 방어 (io.LimitReader 100MB) - Redis Del 에러 로깅 (auth, idempotency) - 로그인 실패 로그에서 username 제거 - os.Remove 에러 로깅 모니터링: - Prometheus 메트릭 미들웨어 + /metrics 엔드포인트 - http_requests_total, http_request_duration_seconds 등 4개 메트릭 테스트: - download (11), chain (10), bossraid (20) = 41개 단위 테스트 기타: - DB 모델 GORM 인덱스 태그 추가 - launcherHash 필드 + hashFileToHex() 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
554 lines
17 KiB
Go
554 lines
17 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"a301_server/pkg/config"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/redis/go-redis/v9"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const refreshTokenExpiry = 7 * 24 * time.Hour
|
|
|
|
var ssafyHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
|
|
|
type Claims struct {
|
|
UserID uint `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type Service struct {
|
|
repo *Repository
|
|
rdb *redis.Client
|
|
walletCreator func(userID uint) error
|
|
profileCreator func(userID uint) error
|
|
}
|
|
|
|
func NewService(repo *Repository, rdb *redis.Client) *Service {
|
|
return &Service{repo: repo, rdb: rdb}
|
|
}
|
|
|
|
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
|
|
s.walletCreator = fn
|
|
}
|
|
|
|
func (s *Service) SetProfileCreator(fn func(userID uint) error) {
|
|
s.profileCreator = fn
|
|
}
|
|
|
|
func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) {
|
|
user, err = s.repo.FindByUsername(username)
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
|
}
|
|
return "", "", nil, fmt.Errorf("로그인 처리 중 오류가 발생했습니다")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
|
}
|
|
|
|
accessToken, err = s.issueAccessToken(user)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
refreshToken, err = s.issueRefreshToken(user)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
return accessToken, refreshToken, user, nil
|
|
}
|
|
|
|
func (s *Service) issueAccessToken(user *User) (string, error) {
|
|
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
|
|
claims := &Claims{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Role: string(user.Role),
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
|
if err != nil {
|
|
return "", fmt.Errorf("토큰 생성에 실패했습니다")
|
|
}
|
|
|
|
key := fmt.Sprintf("session:%d", user.ID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.rdb.Set(ctx, key, tokenStr, expiry).Err(); err != nil {
|
|
return "", fmt.Errorf("세션 저장에 실패했습니다")
|
|
}
|
|
|
|
return tokenStr, nil
|
|
}
|
|
|
|
func (s *Service) issueRefreshToken(user *User) (string, error) {
|
|
claims := &Claims{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Role: string(user.Role),
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString([]byte(config.C.RefreshSecret))
|
|
if err != nil {
|
|
return "", fmt.Errorf("리프레시 토큰 생성에 실패했습니다")
|
|
}
|
|
|
|
key := fmt.Sprintf("refresh:%d", user.ID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.rdb.Set(ctx, key, tokenStr, refreshTokenExpiry).Err(); err != nil {
|
|
return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다")
|
|
}
|
|
|
|
return tokenStr, nil
|
|
}
|
|
|
|
// Refresh validates a refresh token and issues a new access + refresh token pair (rotation).
|
|
func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshToken string, err error) {
|
|
token, err := jwt.ParseWithClaims(refreshTokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method")
|
|
}
|
|
return []byte(config.C.RefreshSecret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return "", "", fmt.Errorf("유효하지 않은 리프레시 토큰입니다")
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "", "", fmt.Errorf("토큰 파싱 실패")
|
|
}
|
|
|
|
// Redis에서 저장된 리프레시 토큰과 비교
|
|
key := fmt.Sprintf("refresh:%d", claims.UserID)
|
|
refreshCtx, refreshCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer refreshCancel()
|
|
stored, err := s.rdb.Get(refreshCtx, key).Result()
|
|
if err != nil || stored != refreshTokenStr {
|
|
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
|
|
}
|
|
|
|
// Look up the current user from DB to avoid using stale role from JWT claims
|
|
user, dbErr := s.repo.FindByID(claims.UserID)
|
|
if dbErr != nil {
|
|
return "", "", fmt.Errorf("유저를 찾을 수 없습니다")
|
|
}
|
|
|
|
newAccessToken, err = s.issueAccessToken(user)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// 리프레시 토큰 로테이션
|
|
newRefreshToken, err = s.issueRefreshToken(user)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return newAccessToken, newRefreshToken, nil
|
|
}
|
|
|
|
func (s *Service) Logout(userID uint) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
sessionKey := fmt.Sprintf("session:%d", userID)
|
|
refreshKey := fmt.Sprintf("refresh:%d", userID)
|
|
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
|
|
}
|
|
|
|
func (s *Service) GetAllUsers(offset, limit int) ([]User, error) {
|
|
return s.repo.FindAll(offset, limit)
|
|
}
|
|
|
|
func (s *Service) UpdateRole(id uint, role Role) error {
|
|
return s.repo.UpdateRole(id, role)
|
|
}
|
|
|
|
func (s *Service) DeleteUser(id uint) error {
|
|
if err := s.repo.Delete(id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up Redis sessions for deleted user
|
|
delCtx, delCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer delCancel()
|
|
sessionKey := fmt.Sprintf("session:%d", id)
|
|
refreshKey := fmt.Sprintf("refresh:%d", id)
|
|
if err := s.rdb.Del(delCtx, sessionKey, refreshKey).Err(); err != nil {
|
|
log.Printf("WARNING: failed to delete Redis sessions for user %d: %v", id, err)
|
|
}
|
|
|
|
// TODO: Clean up wallet and profile data via cross-service calls
|
|
// (walletCreator/profileCreator are creation-only; deletion callbacks are not yet wired up)
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateLaunchTicket generates a one-time ticket that the game launcher
|
|
// exchanges for the real JWT. The ticket expires in 30 seconds and can only
|
|
// be redeemed once, preventing token exposure in URLs or browser history.
|
|
func (s *Service) CreateLaunchTicket(userID uint) (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("generate ticket: %w", err)
|
|
}
|
|
ticket := hex.EncodeToString(buf)
|
|
|
|
// Store ticket → userID mapping in Redis with 30s TTL
|
|
key := fmt.Sprintf("launch_ticket:%s", ticket)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.rdb.Set(ctx, key, userID, 30*time.Second).Err(); err != nil {
|
|
return "", fmt.Errorf("store ticket: %w", err)
|
|
}
|
|
return ticket, nil
|
|
}
|
|
|
|
// RedeemLaunchTicket exchanges a one-time ticket for the user's access token.
|
|
// The ticket is deleted immediately after use (one-time).
|
|
func (s *Service) RedeemLaunchTicket(ticket string) (string, error) {
|
|
key := fmt.Sprintf("launch_ticket:%s", ticket)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Atomically get and delete (one-time use)
|
|
userIDStr, err := s.rdb.GetDel(ctx, key).Result()
|
|
if err != nil {
|
|
return "", fmt.Errorf("유효하지 않거나 만료된 티켓입니다")
|
|
}
|
|
|
|
var userID uint
|
|
if _, err := fmt.Sscanf(userIDStr, "%d", &userID); err != nil {
|
|
return "", fmt.Errorf("invalid ticket data")
|
|
}
|
|
|
|
user, err := s.repo.FindByID(userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("유저를 찾을 수 없습니다")
|
|
}
|
|
|
|
accessToken, err := s.issueAccessToken(user)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return accessToken, nil
|
|
}
|
|
|
|
func (s *Service) Register(username, password string) error {
|
|
if _, err := s.repo.FindByUsername(username); err == nil {
|
|
return fmt.Errorf("이미 사용 중인 아이디입니다")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("비밀번호 처리에 실패했습니다")
|
|
}
|
|
|
|
return s.repo.Transaction(func(txRepo *Repository) error {
|
|
user := &User{Username: username, PasswordHash: string(hash), Role: RoleUser}
|
|
if err := txRepo.Create(user); err != nil {
|
|
return err
|
|
}
|
|
if s.walletCreator != nil {
|
|
if err := s.walletCreator(user.ID); err != nil {
|
|
return fmt.Errorf("wallet creation failed: %w", err)
|
|
}
|
|
}
|
|
if s.profileCreator != nil {
|
|
if err := s.profileCreator(user.ID); err != nil {
|
|
return fmt.Errorf("profile creation failed: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// GetSSAFYLoginURL returns the SSAFY OAuth authorization URL with a random
|
|
// state parameter for CSRF protection. The state is stored in Redis with a
|
|
// 5-minute TTL and must be verified in the callback.
|
|
func (s *Service) GetSSAFYLoginURL() (string, error) {
|
|
stateBytes := make([]byte, 16)
|
|
if _, err := rand.Read(stateBytes); err != nil {
|
|
return "", fmt.Errorf("state 생성 실패: %w", err)
|
|
}
|
|
state := hex.EncodeToString(stateBytes)
|
|
|
|
// Store state in Redis with 5-minute TTL for one-time verification
|
|
key := fmt.Sprintf("ssafy_state:%s", state)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil {
|
|
return "", fmt.Errorf("state 저장 실패: %w", err)
|
|
}
|
|
|
|
params := url.Values{
|
|
"client_id": {config.C.SSAFYClientID},
|
|
"redirect_uri": {config.C.SSAFYRedirectURI},
|
|
"response_type": {"code"},
|
|
"state": {state},
|
|
}
|
|
return "https://project.ssafy.com/oauth/sso-check?" + params.Encode(), nil
|
|
}
|
|
|
|
// ExchangeSSAFYCode exchanges an authorization code for SSAFY tokens.
|
|
func (s *Service) ExchangeSSAFYCode(code string) (*SSAFYTokenResponse, error) {
|
|
data := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"client_id": {config.C.SSAFYClientID},
|
|
"client_secret": {config.C.SSAFYClientSecret},
|
|
"redirect_uri": {config.C.SSAFYRedirectURI},
|
|
"code": {code},
|
|
}
|
|
|
|
resp, err := ssafyHTTPClient.Post(
|
|
"https://project.ssafy.com/ssafy/oauth2/token",
|
|
"application/x-www-form-urlencoded;charset=utf-8",
|
|
strings.NewReader(data.Encode()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SSAFY 토큰 요청 실패: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SSAFY 토큰 응답 읽기 실패: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("SSAFY 토큰 발급 실패 (status %d): %s", resp.StatusCode, string(body))
|
|
return nil, fmt.Errorf("SSAFY 인증에 실패했습니다")
|
|
}
|
|
|
|
var tokenResp SSAFYTokenResponse
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("SSAFY 토큰 응답 파싱 실패: %v", err)
|
|
}
|
|
return &tokenResp, nil
|
|
}
|
|
|
|
// GetSSAFYUserInfo fetches user info from SSAFY using an access token.
|
|
func (s *Service) GetSSAFYUserInfo(accessToken string) (*SSAFYUserInfo, error) {
|
|
req, err := http.NewRequest("GET", "https://project.ssafy.com/ssafy/resources/userInfo", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SSAFY 사용자 정보 요청 생성 실패: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Content-type", "application/x-www-form-urlencoded;charset=utf-8")
|
|
|
|
resp, err := ssafyHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SSAFY 사용자 정보 요청 실패: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SSAFY 사용자 정보 응답 읽기 실패: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("SSAFY 사용자 정보 조회 실패 (status %d): %s", resp.StatusCode, string(body))
|
|
return nil, fmt.Errorf("SSAFY 사용자 정보를 가져올 수 없습니다")
|
|
}
|
|
|
|
var userInfo SSAFYUserInfo
|
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
|
return nil, fmt.Errorf("SSAFY 사용자 정보 파싱 실패: %v", err)
|
|
}
|
|
return &userInfo, nil
|
|
}
|
|
|
|
// SSAFYLogin handles the full SSAFY OAuth callback: exchange code, get user info, find or create user, issue tokens.
|
|
// The state parameter is verified against Redis (one-time use via GetDel) for CSRF protection.
|
|
func (s *Service) SSAFYLogin(code, state string) (accessToken, refreshToken string, user *User, err error) {
|
|
// Verify CSRF state parameter (one-time use)
|
|
if state == "" {
|
|
return "", "", nil, fmt.Errorf("state 파라미터가 필요합니다")
|
|
}
|
|
stateKey := fmt.Sprintf("ssafy_state:%s", state)
|
|
stateCtx, stateCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer stateCancel()
|
|
val, err := s.rdb.GetDel(stateCtx, stateKey).Result()
|
|
if err != nil || val != "1" {
|
|
return "", "", nil, fmt.Errorf("유효하지 않거나 만료된 state 파라미터입니다")
|
|
}
|
|
|
|
tokenResp, err := s.ExchangeSSAFYCode(code)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
userInfo, err := s.GetSSAFYUserInfo(tokenResp.AccessToken)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
// SSAFY ID로 기존 사용자 조회
|
|
user, err = s.repo.FindBySsafyID(userInfo.UserID)
|
|
if err != nil {
|
|
// 신규 사용자 자동 가입
|
|
randomBytes := make([]byte, 16)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return "", "", nil, fmt.Errorf("보안 난수 생성 실패: %v", err)
|
|
}
|
|
randomPassword := hex.EncodeToString(randomBytes)
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", "", nil, fmt.Errorf("계정 생성 실패")
|
|
}
|
|
|
|
ssafyID := userInfo.UserID
|
|
// SSAFY ID에서 영문 소문자+숫자만 추출하여 안전한 username 생성
|
|
// NOTE: Username collision is handled by the DB unique constraint.
|
|
// If collision occurs, the transaction will rollback and return a generic error.
|
|
// A retry with random suffix could improve UX but is not critical.
|
|
safeID := sanitizeForUsername(ssafyID)
|
|
if safeID == "" {
|
|
safeID = hex.EncodeToString(randomBytes[:8])
|
|
}
|
|
username := "ssafy_" + safeID
|
|
if len(username) > 50 {
|
|
username = username[:50]
|
|
}
|
|
|
|
err = s.repo.Transaction(func(txRepo *Repository) error {
|
|
user = &User{
|
|
Username: username,
|
|
PasswordHash: string(hash),
|
|
Role: RoleUser,
|
|
SsafyID: &ssafyID,
|
|
}
|
|
if err := txRepo.Create(user); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.walletCreator != nil {
|
|
if err := s.walletCreator(user.ID); err != nil {
|
|
return fmt.Errorf("wallet creation failed: %w", err)
|
|
}
|
|
}
|
|
if s.profileCreator != nil {
|
|
if err := s.profileCreator(user.ID); err != nil {
|
|
return fmt.Errorf("profile creation failed: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.Printf("SSAFY user creation transaction failed: %v", err)
|
|
return "", "", nil, fmt.Errorf("계정 생성 실패: %v", err)
|
|
}
|
|
}
|
|
|
|
accessToken, err = s.issueAccessToken(user)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
refreshToken, err = s.issueRefreshToken(user)
|
|
if err != nil {
|
|
return "", "", nil, err
|
|
}
|
|
|
|
return accessToken, refreshToken, user, nil
|
|
}
|
|
|
|
// VerifyToken validates a JWT and its Redis session, returning (username, error).
|
|
func (s *Service) VerifyToken(tokenStr string) (string, error) {
|
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method")
|
|
}
|
|
return []byte(config.C.JWTSecret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return "", fmt.Errorf("유효하지 않은 토큰입니다")
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "", fmt.Errorf("토큰 파싱 실패")
|
|
}
|
|
|
|
key := fmt.Sprintf("session:%d", claims.UserID)
|
|
verifyCtx, verifyCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer verifyCancel()
|
|
stored, err := s.rdb.Get(verifyCtx, key).Result()
|
|
if err != nil || stored != tokenStr {
|
|
return "", fmt.Errorf("만료되었거나 로그아웃된 세션입니다")
|
|
}
|
|
|
|
return claims.Username, nil
|
|
}
|
|
|
|
// sanitizeForUsername strips characters that are not [a-z0-9_-].
|
|
func sanitizeForUsername(s string) string {
|
|
var b strings.Builder
|
|
for _, c := range strings.ToLower(s) {
|
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
|
|
b.WriteRune(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// NOTE: EnsureAdmin does not use a transaction for wallet/profile creation.
|
|
// If these fail, the admin user exists without a wallet/profile.
|
|
// This is acceptable because EnsureAdmin runs once at startup and failures
|
|
// are logged as warnings. A restart will skip user creation (already exists).
|
|
func (s *Service) EnsureAdmin(username, password string) error {
|
|
if _, err := s.repo.FindByUsername(username); err == nil {
|
|
return nil
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user := &User{
|
|
Username: username,
|
|
PasswordHash: string(hash),
|
|
Role: RoleAdmin,
|
|
}
|
|
if err := s.repo.Create(user); err != nil {
|
|
return err
|
|
}
|
|
if s.walletCreator != nil {
|
|
if err := s.walletCreator(user.ID); err != nil {
|
|
log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err)
|
|
}
|
|
}
|
|
if s.profileCreator != nil {
|
|
if err := s.profileCreator(user.ID); err != nil {
|
|
log.Printf("WARNING: admin profile creation failed for user %d: %v", user.ID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|