跳到主要内容

Keycloak Authentication

Keycloak serves as the enterprise identity provider for iHospita, providing OAuth2/OIDC authentication, multi-factor authentication, and multi-tenant user management.


Architecture

┌─────────────────────────────────────────────────────────────────┐
│ KEYCLOAK ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KEYCLOAK SERVER │ │
│ │ │ │
│ │ Realms: │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ master │ │ ihospita │ │ samaki │ ... │ │
│ │ │ (admin) │ │(console) │ │(hospital)│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ Features: │ │
│ │ - OAuth2 / OpenID Connect │ │
│ │ - Two-Factor Authentication (Email OTP) │ │
│ │ - User Federation │ │
│ │ - Role-Based Access Control │ │
│ │ - Session Management │ │
│ │ - Impersonation │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KEYCLOAK DATABASE │ │
│ │ (PostgreSQL) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

Multi-Tenant Realm Structure

Each hospital has its own isolated Keycloak realm:

Keycloak
├── master realm (Keycloak admin only)
├── ihospita realm (Console users)
│ └── Roles: SUPER_ADMIN, ADMIN, SUPPORT, BILLING
├── samaki realm (Hospital A)
│ └── Roles: OWNER, ADMIN, DOCTOR, NURSE, RECEPTIONIST, CASHIER
├── royal realm (Hospital B)
│ └── Roles: OWNER, ADMIN, DOCTOR, NURSE, RECEPTIONIST, CASHIER
└── ... (one realm per hospital)

Docker Compose Configuration

# docker-compose.keycloak.yml
version: '3.8'

services:
keycloak-db:
image: postgres:15-alpine
container_name: ihospita-keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
volumes:
- keycloak_data:/var/lib/postgresql/data
networks:
- ihospita-network

keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: ihospita-keycloak
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
KC_HOSTNAME: auth.ihospita.com
KC_HOSTNAME_STRICT: false
KC_HTTP_ENABLED: true
KC_PROXY: edge
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
ports:
- "8080:8080"
depends_on:
- keycloak-db
networks:
- ihospita-network

networks:
ihospita-network:
external: true

volumes:
keycloak_data:

Authentication Flow


JWT Token Structure

{
"exp": 1703862400,
"iat": 1703833600,
"iss": "https://auth.ihospita.com/realms/samaki",
"sub": "user-uuid-here",
"typ": "Bearer",
"azp": "ihospita-portal",
"session_state": "session-uuid",
"email": "doctor@samaki.com",
"email_verified": true,
"name": "Dr. John Doe",
"preferred_username": "dr.john",
"realm_access": {
"roles": ["DOCTOR", "default-roles-samaki"]
},
"resource_access": {
"ihospita-portal": {
"roles": ["user"]
}
},
"hospital_id": "hospital-uuid",
"hospital_prefix": "samaki",
"clinic_id": "clinic-uuid"
}

Realm Configuration

Hospital Realm Settings

{
"realm": "samaki",
"displayName": "Samaki Medical Center",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"accessTokenLifespan": 28800,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400
}

Role Definitions

{
"roles": {
"realm": [
{
"name": "OWNER",
"description": "Hospital owner with full access"
},
{
"name": "ADMIN",
"description": "Hospital administrator"
},
{
"name": "DOCTOR",
"description": "Medical doctor"
},
{
"name": "NURSE",
"description": "Nursing staff"
},
{
"name": "RECEPTIONIST",
"description": "Front desk staff"
},
{
"name": "CASHIER",
"description": "Billing staff"
},
{
"name": "LAB_TECHNICIAN",
"description": "Laboratory staff"
},
{
"name": "PHARMACIST",
"description": "Pharmacy staff"
}
]
}
}

Two-Factor Authentication

Email OTP Configuration

{
"requiredActions": ["CONFIGURE_TOTP"],
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA256",
"otpPolicyDigits": 6,
"otpPolicyPeriod": 300,
"otpPolicyLookAheadWindow": 1
}

Email Template

<!-- Email OTP Template -->
<html>
<body>
<h2>iHospita Login Verification</h2>
<p>Your verification code is:</p>
<h1 style="font-size: 32px; letter-spacing: 5px;">${code}</h1>
<p>This code expires in 5 minutes.</p>
<p>If you did not request this code, please ignore this email.</p>
</body>
</html>

NestJS Integration

Keycloak Strategy

// keycloak.strategy.ts
@Injectable()
export class KeycloakStrategy extends PassportStrategy(Strategy, 'keycloak') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKeyProvider: async (request, rawJwtToken, done) => {
const decoded = jwt.decode(rawJwtToken, { complete: true });
const issuer = decoded.payload['iss'];
const publicKey = await this.getRealmPublicKey(issuer);
done(null, publicKey);
},
});
}

async validate(payload: JwtPayload): Promise<User> {
return {
id: payload.sub,
email: payload.email,
roles: payload.realm_access?.roles || [],
hospitalId: payload.hospital_id,
hospitalPrefix: payload.hospital_prefix,
};
}
}

Auth Guard

// keycloak-auth.guard.ts
@Injectable()
export class KeycloakAuthGuard extends AuthGuard('keycloak') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}

handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}

Roles Guard

// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);

if (!requiredRoles) {
return true;
}

const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}

Impersonation

Console support staff can impersonate hospital users for troubleshooting:

// impersonation.service.ts
@Injectable()
export class ImpersonationService {
async impersonate(
supportUserId: string,
targetUserId: string,
targetRealm: string,
): Promise<ImpersonationSession> {
// Log impersonation start
await this.auditLog.create({
action: 'IMPERSONATION_START',
actorId: supportUserId,
targetId: targetUserId,
targetRealm,
timestamp: new Date(),
});

// Get impersonation token from Keycloak
const token = await this.keycloakAdmin.users.impersonate({
realm: targetRealm,
id: targetUserId,
});

return {
token,
expiresAt: new Date(Date.now() + 3600000), // 1 hour
};
}
}

Session Management

Session Timeout: 8 hours

{
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"accessTokenLifespan": 28800
}

Token Refresh

// Portal: Token refresh
async function refreshToken(refreshToken: string) {
const response = await fetch(
`${KEYCLOAK_URL}/realms/${realm}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
}
);
return response.json();
}

Security Best Practices

  1. SSL Required: All connections use HTTPS
  2. Brute Force Protection: Account lockout after 5 failed attempts
  3. Password Policy: Minimum 8 characters, complexity required
  4. Session Security: Secure cookies, CSRF protection
  5. Audit Logging: All authentication events logged