feat: first-time setup flow — no .env required for deployment
- Add GET /auth/status endpoint (returns needsSetup when no admin exists) - Add POST /auth/setup endpoint (public first-admin registration) - Add IsAdminExists + SetupInitialAdmin methods to AuthService - Frontend: detect needsSetup on load, show setup page with admin registration - Frontend: fall back to login page when setup is already complete - Docker compose: env_file already optional (required: false), no changes needed - Bootstrap: auto-detect BOOTSTRAP_CLUSTERS without separate enable flag
This commit is contained in:
@ -254,6 +254,8 @@ func setupRouter(
|
||||
// ===== 认证路由 =====
|
||||
api.HandleFunc("/auth/login", authHandler.Login).Methods(http.MethodPost)
|
||||
api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods(http.MethodPost)
|
||||
api.HandleFunc("/auth/status", authHandler.AuthStatus).Methods(http.MethodGet)
|
||||
api.HandleFunc("/auth/setup", authHandler.Setup).Methods(http.MethodPost)
|
||||
|
||||
protected := api.PathPrefix("").Subrouter()
|
||||
protected.Use(authMiddleware(authService))
|
||||
|
||||
@ -250,6 +250,54 @@ func loginRateLimitKey(r *http.Request, username string) string {
|
||||
return strings.ToLower(strings.TrimSpace(username)) + "|" + client
|
||||
}
|
||||
|
||||
// AuthStatus returns whether the system needs initial setup (no admin exists).
|
||||
func (h *AuthHandler) AuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
hasAdmin, err := h.authService.IsAdminExists(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to check status", err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"needsSetup": !hasAdmin,
|
||||
"hasUsers": hasAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// Setup creates the first admin user. Only works when no admin exists.
|
||||
func (h *AuthHandler) Setup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" {
|
||||
respondError(w, http.StatusBadRequest, "Missing fields", "username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.authService.SetupInitialAdmin(r.Context(), req.Username, req.Password, req.Email)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to create initial admin")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate access token immediately
|
||||
accessToken, refreshToken, _, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Admin created but login failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]string{
|
||||
"accessToken": accessToken,
|
||||
"refreshToken": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse {
|
||||
workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID)
|
||||
return &dto.UserResponse{
|
||||
|
||||
@ -80,6 +80,55 @@ type UserWorkspaceOptions struct {
|
||||
QuotaGPUMem string
|
||||
}
|
||||
|
||||
// IsAdminExists checks whether any admin user already exists in the database.
|
||||
func (s *AuthService) IsAdminExists(ctx context.Context) (bool, error) {
|
||||
users, err := s.userRepo.List(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Role == authz.RoleAdmin {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SetupInitialAdmin creates the first admin user. Fails if an admin already exists.
|
||||
func (s *AuthService) SetupInitialAdmin(ctx context.Context, username, password, email string) (*entity.User, error) {
|
||||
hasAdmin, err := s.IsAdminExists(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasAdmin {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := s.passwordHasher.Hash(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
email = username + "@local.ocdp"
|
||||
}
|
||||
|
||||
user := entity.NewUser(username, passwordHash, email)
|
||||
user.ID = uuid.New().String()
|
||||
user.Role = authz.RoleAdmin
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
user.IsActive = true
|
||||
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user