Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/modules/iam/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type LoginContext struct {

type AuthUsecase interface {
Login(ctx context.Context, input dto.LoginRequest, lc LoginContext) (*dto.LoginResponse, error)
EmptyLogin(ctx context.Context, input dto.LoginRequest, lc LoginContext) (*dto.EmptyLoginResponse, error)
Me(ctx context.Context, userID uuid.UUID) (*dto.UserResponse, error)
UpdateMe(ctx context.Context, userID uuid.UUID, input dto.UpdateMeRequest) (*dto.UserResponse, error)
ChangePassword(ctx context.Context, userID uuid.UUID, input dto.ChangePasswordRequest) error
Expand Down
4 changes: 4 additions & 0 deletions backend/modules/iam/dto/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}

type EmptyLoginResponse struct {
Token string `json:"token"`
}

type LogoutRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
Expand Down
33 changes: 33 additions & 0 deletions backend/modules/iam/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}

// @Summary Empty login
// @Description Verifies credentials and issues a claimless password token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param input body dto.LoginRequest true "Credentials"
// @Success 200 {object} dto.EmptyLoginResponse
// @Failure 401 {object} map[string]string
// @Router /auth/empty_login [post]
func (h *AuthHandler) EmptyLogin(c *gin.Context) {
var input dto.LoginRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "login and password are required"})
return
}
resp, err := h.authUsecase.EmptyLogin(c.Request.Context(), input, loginContext(c))
audit.Record(c, audit_connectors.Event{Action: "auth.empty_login", UserEmail: input.Login},
audit_domain.AUTH_ATTEMPT, audit_domain.AUTH_SUCCESS, err)
if err != nil {
switch {
case errors.Is(err, domain.ErrInvalidCredentials):
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
case errors.Is(err, domain.ErrUserInactive):
c.JSON(http.StatusForbidden, gin.H{"error": "user is deactivated"})
default:
_ = catcher.Error("empty login failed", err, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
}
return
}
c.JSON(http.StatusOK, resp)
}

// @Summary Refresh access token
// @Tags Auth
// @Accept json
Expand Down
1 change: 1 addition & 0 deletions backend/modules/iam/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func RegisterRoutes(api *gin.RouterGroup, module *Module, userAuth gin.HandlerFu

authGroup := api.Group("/auth")
authGroup.POST("/login", auth.Login)
authGroup.POST("/empty_login", auth.EmptyLogin)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/tfa/verify-code", tfa.VerifyLoginCode)

Expand Down
24 changes: 24 additions & 0 deletions backend/modules/iam/usecase/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,30 @@ func (u *authUsecase) Login(ctx context.Context, input dto.LoginRequest, lc conn
return &dto.LoginResponse{TokenPair: *pair, User: dto.ToUserResponse(*user, false)}, nil
}

// EmptyLogin verifies the same credentials as Login and issues a token with no
// claims: the password_token second factor. It ignores directory accounts and
// TFA — the whole point is proving the password.
func (u *authUsecase) EmptyLogin(ctx context.Context, input dto.LoginRequest, _ connectors.LoginContext) (*dto.EmptyLoginResponse, error) {
user, err := u.userRepo.FindByEmail(ctx, input.Login)
if err != nil {
return nil, err
}
if user == nil || user.IdentityProviderID != nil || user.PasswordHash == nil || *user.PasswordHash == "" {
return nil, domain.ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(input.Password)); err != nil {
return nil, domain.ErrInvalidCredentials
}
if err := statusError(user.Status); err != nil {
return nil, err
}
token, err := u.signer.SignEmpty(5 * time.Minute)
if err != nil {
return nil, err
}
return &dto.EmptyLoginResponse{Token: token}, nil
}

// tryDirectory asks the tenant's LDAP providers whether these credentials are
// theirs. It answers nil when none recognised them, which leaves the ordinary
// failure path to say so.
Expand Down
24 changes: 24 additions & 0 deletions backend/modules/soar/handler/command_ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ func (h *CommandWSHandler) authenticate(c *gin.Context) bool {
return false
}

// requirePasswordToken enforces the second factor: a fresh JWT named
// password_token, signed by this backend, must accompany the request.
func (h *CommandWSHandler) requirePasswordToken(c *gin.Context) bool {
raw := c.Query("password_token")
if raw == "" {
if header := c.GetHeader("X-Password-Token"); header != "" {
raw = header
}
}
if raw == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing password_token"})
return false
}
if _, err := h.signer.Verify(raw); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired password_token"})
return false
}
return true
}

type wsCommandRequest struct {
Command string `json:"command"`
OriginType string `json:"originType"`
Expand Down Expand Up @@ -128,6 +148,10 @@ func (h *CommandWSHandler) CommandStream(c *gin.Context) {
return
}

if !h.requirePasswordToken(c) {
return
}

actor := middleware.ActorFromGin(c)
if err := authz.RequirePermission(actor, "soar.write"); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "missing required permission: soar.write"})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/auth/services/auth-http.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ApiError, createApiClient } from '@/shared/lib/api-client'
import type {
ChangePasswordRequest,
EmptyLoginResponse,
LoginRequest,
LoginResponse,
ResetPasswordFinishRequest,
Expand All @@ -21,6 +22,9 @@ export { ApiError as AuthHttpError }
export const authHttpService = {
/* ---- login / session lifecycle ---- */
login: (input: LoginRequest) => api.post<LoginResponse>('/auth/login', input),
// Password proof for the agent command second factor: a token with no
// claims, ignoring TFA — the caller must already be a signed-in user.
emptyLogin: (input: LoginRequest) => api.post<EmptyLoginResponse>('/auth/empty_login', input),
// Second factor: exchange the pre-auth token + code for a real token pair.
verifyTfaCode: (input: TfaVerifyCodeRequest) =>
api.post<LoginResponse>('/auth/tfa/verify-code', input),
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/auth/types/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export interface TfaVerifyCodeRequest {
code: string
}

export interface EmptyLoginResponse {
token: string
}

export interface ChangePasswordRequest {
current_password: string
new_password: string
Expand Down
Loading
Loading