- auth/auth.go: add DummyHash() which uses sync.Once to compute
HashPassword("dummy-password-for-timing-only", DefaultArgonParams())
on first call; subsequent calls return the cached PHC string;
add sync to imports
- auth/auth_test.go: TestDummyHashIsValidPHC verifies the hash
parses and verifies correctly; TestDummyHashIsCached verifies
sync.Once behaviour; TestDummyHashMatchesDefaultParams verifies
embedded m/t/p match DefaultArgonParams()
- server/server.go, grpcserver/auth.go, ui/ui.go: replace five
hardcoded PHC strings with auth.DummyHash() calls
- AUDIT.md: mark F-07 as fixed
Security: the previous hardcoded hash used a 6-byte salt and
6-byte output ("testsalt"/"testhash" in base64), which Argon2id
verifies faster than a real 16-byte-salt / 32-byte-output hash.
This timing gap was measurable and could aid user enumeration.
auth.DummyHash() uses identical parameters and full-length salt
and output, so dummy verification timing matches real timing
exactly, regardless of future parameter changes.
266 lines
9.4 KiB
Go
266 lines
9.4 KiB
Go
// authServiceServer implements mciasv1.AuthServiceServer.
|
|
// All handlers delegate to the same internal packages as the REST server.
|
|
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
|
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
|
)
|
|
|
|
type authServiceServer struct {
|
|
mciasv1.UnimplementedAuthServiceServer
|
|
s *Server
|
|
}
|
|
|
|
// Login authenticates a user and issues a JWT.
|
|
// Public RPC — no auth interceptor required.
|
|
//
|
|
// Security: Identical to the REST handleLogin: always runs Argon2 for unknown
|
|
// users to prevent timing-based user enumeration. Generic error returned
|
|
// regardless of which step failed.
|
|
func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest) (*mciasv1.LoginResponse, error) {
|
|
if req.Username == "" || req.Password == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "username and password are required")
|
|
}
|
|
|
|
ip := peerIP(ctx)
|
|
|
|
acct, err := a.s.db.GetAccountByUsername(req.Username)
|
|
if err != nil {
|
|
// Security: run dummy Argon2 to equalise timing for unknown users.
|
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
|
a.s.db.WriteAuditEvent(model.EventLoginFail, nil, nil, ip, //nolint:errcheck // audit failure is non-fatal
|
|
fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
|
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
|
}
|
|
|
|
if acct.Status != model.AccountStatusActive {
|
|
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_inactive"}`) //nolint:errcheck
|
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
|
}
|
|
|
|
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
|
if err != nil || !ok {
|
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"wrong_password"}`) //nolint:errcheck
|
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
|
}
|
|
|
|
if acct.TOTPRequired {
|
|
if req.TotpCode == "" {
|
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
|
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
|
}
|
|
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
|
if err != nil {
|
|
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
|
|
if err != nil || !valid {
|
|
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
|
|
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
|
}
|
|
}
|
|
|
|
expiry := a.s.cfg.DefaultExpiry()
|
|
roles, err := a.s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
for _, r := range roles {
|
|
if r == "admin" {
|
|
expiry = a.s.cfg.AdminExpiry()
|
|
break
|
|
}
|
|
}
|
|
|
|
tokenStr, claims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
a.s.logger.Error("issue token", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
if err := a.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
|
a.s.logger.Error("track token", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventLoginOK, &acct.ID, nil, ip, "") //nolint:errcheck
|
|
a.s.db.WriteAuditEvent(model.EventTokenIssued, &acct.ID, nil, ip, //nolint:errcheck
|
|
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
|
|
|
|
return &mciasv1.LoginResponse{
|
|
Token: tokenStr,
|
|
ExpiresAt: timestamppb.New(claims.ExpiresAt),
|
|
}, nil
|
|
}
|
|
|
|
// Logout revokes the caller's current JWT.
|
|
func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest) (*mciasv1.LogoutResponse, error) {
|
|
claims := claimsFromContext(ctx)
|
|
if err := a.s.db.RevokeToken(claims.JTI, "logout"); err != nil {
|
|
a.s.logger.Error("revoke token on logout", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
a.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"jti":%q,"reason":"logout"}`, claims.JTI))
|
|
return &mciasv1.LogoutResponse{}, nil
|
|
}
|
|
|
|
// RenewToken exchanges the caller's token for a new one.
|
|
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
|
|
claims := claimsFromContext(ctx)
|
|
|
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
|
}
|
|
if acct.Status != model.AccountStatusActive {
|
|
return nil, status.Error(codes.Unauthenticated, "account inactive")
|
|
}
|
|
|
|
roles, err := a.s.db.GetRoles(acct.ID)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
expiry := a.s.cfg.DefaultExpiry()
|
|
for _, r := range roles {
|
|
if r == "admin" {
|
|
expiry = a.s.cfg.AdminExpiry()
|
|
break
|
|
}
|
|
}
|
|
|
|
newTokenStr, newClaims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
// Security: revoke old + track new in a single transaction (F-03) so that a
|
|
// failure between the two steps cannot leave the user with no valid token.
|
|
if err := a.s.db.RenewToken(claims.JTI, "renewed", newClaims.JTI, acct.ID, newClaims.IssuedAt, newClaims.ExpiresAt); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventTokenRenewed, &acct.ID, nil, peerIP(ctx), //nolint:errcheck
|
|
fmt.Sprintf(`{"old_jti":%q,"new_jti":%q}`, claims.JTI, newClaims.JTI))
|
|
|
|
return &mciasv1.RenewTokenResponse{
|
|
Token: newTokenStr,
|
|
ExpiresAt: timestamppb.New(newClaims.ExpiresAt),
|
|
}, nil
|
|
}
|
|
|
|
// EnrollTOTP begins TOTP enrollment for the calling account.
|
|
func (a *authServiceServer) EnrollTOTP(ctx context.Context, _ *mciasv1.EnrollTOTPRequest) (*mciasv1.EnrollTOTPResponse, error) {
|
|
claims := claimsFromContext(ctx)
|
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
|
}
|
|
|
|
rawSecret, b32Secret, err := auth.GenerateTOTPSecret()
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
secretEnc, secretNonce, err := crypto.SealAESGCM(a.s.masterKey, rawSecret)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
if err := a.s.db.SetTOTP(acct.ID, secretEnc, secretNonce); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
otpURI := fmt.Sprintf("otpauth://totp/MCIAS:%s?secret=%s&issuer=MCIAS", acct.Username, b32Secret)
|
|
|
|
// Security: secret is shown once here only; the stored form is encrypted.
|
|
return &mciasv1.EnrollTOTPResponse{
|
|
Secret: b32Secret,
|
|
OtpauthUri: otpURI,
|
|
}, nil
|
|
}
|
|
|
|
// ConfirmTOTP confirms TOTP enrollment.
|
|
func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.ConfirmTOTPRequest) (*mciasv1.ConfirmTOTPResponse, error) {
|
|
if req.Code == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "code is required")
|
|
}
|
|
|
|
claims := claimsFromContext(ctx)
|
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
|
}
|
|
if acct.TOTPSecretEnc == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "TOTP enrollment not started")
|
|
}
|
|
|
|
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
valid, err := auth.ValidateTOTP(secret, req.Code)
|
|
if err != nil || !valid {
|
|
return nil, status.Error(codes.Unauthenticated, "invalid TOTP code")
|
|
}
|
|
|
|
// SetTOTP with existing enc/nonce sets totp_required=1, confirming enrollment.
|
|
if err := a.s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventTOTPEnrolled, &acct.ID, nil, peerIP(ctx), "") //nolint:errcheck
|
|
return &mciasv1.ConfirmTOTPResponse{}, nil
|
|
}
|
|
|
|
// RemoveTOTP removes TOTP from an account. Admin only.
|
|
func (a *authServiceServer) RemoveTOTP(ctx context.Context, req *mciasv1.RemoveTOTPRequest) (*mciasv1.RemoveTOTPResponse, error) {
|
|
if err := a.s.requireAdmin(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.AccountId == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
|
}
|
|
|
|
acct, err := a.s.db.GetAccountByUUID(req.AccountId)
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, "account not found")
|
|
}
|
|
|
|
if err := a.s.db.ClearTOTP(acct.ID); err != nil {
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
|
|
a.s.db.WriteAuditEvent(model.EventTOTPRemoved, nil, &acct.ID, peerIP(ctx), "") //nolint:errcheck
|
|
return &mciasv1.RemoveTOTPResponse{}, nil
|
|
}
|
|
|
|
// peerIP extracts the client IP from gRPC peer context.
|
|
func peerIP(ctx context.Context) string {
|
|
p, ok := peer.FromContext(ctx)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
host, _, err := net.SplitHostPort(p.Addr.String())
|
|
if err != nil {
|
|
return p.Addr.String()
|
|
}
|
|
return host
|
|
}
|