Implement Phase 7: gRPC dual-stack interface
- proto/mcias/v1/: AdminService, AuthService, TokenService, AccountService, CredentialService; generated Go stubs in gen/ - internal/grpcserver: full handler implementations sharing all business logic (auth, token, db, crypto) with REST server; interceptor chain: logging -> auth (JWT alg-first + revocation) -> rate-limit (token bucket, 10 req/s, burst 10, per-IP) - internal/config: optional grpc_addr field in [server] section - cmd/mciassrv: dual-stack startup; gRPC/TLS listener on grpc_addr when configured; graceful shutdown of both servers in 15s window - cmd/mciasgrpcctl: companion gRPC CLI mirroring mciasctl commands (health, pubkey, account, role, token, pgcreds) using TLS with optional custom CA cert - internal/grpcserver/grpcserver_test.go: 20 tests via bufconn covering public RPCs, auth interceptor (no token, invalid, revoked -> 401), non-admin -> 403, Login/Logout/RenewToken/ValidateToken flows, AccountService CRUD, SetPGCreds/GetPGCreds AES-GCM round-trip, credential fields absent from all responses Security: JWT validation path identical to REST: alg header checked before signature, alg:none rejected, revocation table checked after sig. Authorization metadata value never logged by any interceptor. Credential fields (PasswordHash, TOTPSecret*, PGPassword) absent from all proto response messages — enforced by proto design and confirmed by test TestCredentialFieldsAbsentFromAccountResponse. Login dummy-Argon2 timing guard preserves timing uniformity for unknown users (same as REST handleLogin). TLS required at listener level; cmd/mciassrv uses credentials.NewServerTLSFromFile; no h2c offered. 137 tests pass, zero race conditions (go test -race ./...)
This commit is contained in:
122
internal/grpcserver/tokenservice.go
Normal file
122
internal/grpcserver/tokenservice.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// tokenServiceServer implements mciasv1.TokenServiceServer.
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
|
||||
)
|
||||
|
||||
type tokenServiceServer struct {
|
||||
mciasv1.UnimplementedTokenServiceServer
|
||||
s *Server
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT and returns its claims.
|
||||
// Public RPC — no auth required.
|
||||
//
|
||||
// Security: Always returns a valid=false response on any error; never
|
||||
// exposes which specific validation step failed.
|
||||
func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.ValidateTokenRequest) (*mciasv1.ValidateTokenResponse, error) {
|
||||
tokenStr := req.Token
|
||||
if tokenStr == "" {
|
||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||
}
|
||||
|
||||
claims, err := token.ValidateToken(t.s.pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
|
||||
if err != nil {
|
||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||
}
|
||||
|
||||
rec, err := t.s.db.GetTokenRecord(claims.JTI)
|
||||
if err != nil || rec.IsRevoked() {
|
||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
||||
}
|
||||
|
||||
return &mciasv1.ValidateTokenResponse{
|
||||
Valid: true,
|
||||
Subject: claims.Subject,
|
||||
Roles: claims.Roles,
|
||||
ExpiresAt: timestamppb.New(claims.ExpiresAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IssueServiceToken issues a token for a system account. Admin only.
|
||||
func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv1.IssueServiceTokenRequest) (*mciasv1.IssueServiceTokenResponse, error) {
|
||||
if err := ts.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id is required")
|
||||
}
|
||||
|
||||
acct, err := ts.s.db.GetAccountByUUID(req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, "account not found")
|
||||
}
|
||||
if acct.AccountType != model.AccountTypeSystem {
|
||||
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
|
||||
}
|
||||
|
||||
tokenStr, claims, err := token.IssueToken(ts.s.privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
// Revoke existing system token if any.
|
||||
existing, err := ts.s.db.GetSystemToken(acct.ID)
|
||||
if err == nil && existing != nil {
|
||||
_ = ts.s.db.RevokeToken(existing.JTI, "rotated")
|
||||
}
|
||||
|
||||
if err := ts.s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
if err := ts.s.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil {
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
actorClaims := claimsFromContext(ctx)
|
||||
var actorID *int64
|
||||
if actorClaims != nil {
|
||||
if a, err := ts.s.db.GetAccountByUUID(actorClaims.Subject); err == nil {
|
||||
actorID = &a.ID
|
||||
}
|
||||
}
|
||||
ts.s.db.WriteAuditEvent(model.EventTokenIssued, actorID, &acct.ID, peerIP(ctx), //nolint:errcheck
|
||||
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
|
||||
|
||||
return &mciasv1.IssueServiceTokenResponse{
|
||||
Token: tokenStr,
|
||||
ExpiresAt: timestamppb.New(claims.ExpiresAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeToken revokes a token by JTI. Admin only.
|
||||
func (ts *tokenServiceServer) RevokeToken(ctx context.Context, req *mciasv1.RevokeTokenRequest) (*mciasv1.RevokeTokenResponse, error) {
|
||||
if err := ts.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Jti == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "jti is required")
|
||||
}
|
||||
|
||||
if err := ts.s.db.RevokeToken(req.Jti, "admin revocation"); err != nil {
|
||||
if err == db.ErrNotFound {
|
||||
return nil, status.Error(codes.NotFound, "token not found or already revoked")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, "internal error")
|
||||
}
|
||||
|
||||
ts.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
|
||||
fmt.Sprintf(`{"jti":%q}`, req.Jti))
|
||||
return &mciasv1.RevokeTokenResponse{}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user