hapi-fhir-oauth2
Tích hợp HAPI FHIR với OAuth2 trong Spring Boot
OAuth2 là một giao thức xác thực và ủy quyền tiêu chuẩn được sử dụng rộng rãi trong các ứng dụng hiện đại, bao gồm cả hệ thống y tế. Khi kết hợp HAPI FHIR với OAuth2, bạn có thể xây dựng ứng dụng y tế tuân thủ SMART on FHIR - một tiêu chuẩn bảo mật cho ứng dụng y tế. Bài viết này sẽ hướng dẫn chi tiết về việc tích hợp HAPI FHIR với OAuth2 trong môi trường Spring Boot.
Giới thiệu về SMART on FHIR và OAuth2
SMART on FHIR (Substitutable Medical Applications, Reusable Technologies) là một tiêu chuẩn mở cho phép ứng dụng y tế chạy trên nhiều nền tảng khác nhau và tích hợp với nhiều hệ thống EHR. SMART on FHIR sử dụng OAuth2 làm cơ chế xác thực và ủy quyền.
OAuth2 trong môi trường y tế cung cấp một số lợi ích quan trọng:
Bảo mật: Xác thực mạnh mẽ và ủy quyền chi tiết
Phạm vi quyền hạn (scopes): Hạn chế quyền truy cập dựa trên nhu cầu thực tế (ví dụ: chỉ đọc hoặc cả đọc và ghi)
Tương thích: Làm việc với nhiều hệ thống khác nhau
Trải nghiệm người dùng: Đăng nhập một lần (single sign-on)
Cài đặt các dependency cần thiết
Để tích hợp HAPI FHIR với OAuth2 trong Spring Boot, bạn cần thêm các dependency sau vào file pom.xml
:
<!-- HAPI FHIR Core -->
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-base</artifactId>
<version>6.4.0</version>
</dependency>
<!-- HAPI FHIR R5 Structures -->
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-structures-r5</artifactId>
<version>6.4.0</version>
</dependency>
<!-- HAPI FHIR Client -->
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-client</artifactId>
<version>6.4.0</version>
</dependency>
<!-- Spring Security OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Spring Security OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- Spring Security Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Cấu hình OAuth2 trong Spring Boot
1. Cấu hình application.yml
Đầu tiên, cấu hình thông tin OAuth2 trong file application.yml
:
spring:
security:
oauth2:
client:
registration:
fhir-client:
client-id: fhir-client
client-secret: your-client-secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: launch/patient,patient/*.read,patient/*.write
provider:
fhir-client:
authorization-uri: https://auth.example.com/oauth2/authorize
token-uri: https://auth.example.com/oauth2/token
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
user-info-uri: https://auth.example.com/userinfo
user-name-attribute: sub
resourceserver:
jwt:
issuer-uri: https://auth.example.com
2. Cấu hình Spring Security với OAuth2
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/home", "/login").permitAll()
.requestMatchers("/fhir/metadata").permitAll() // Cho phép truy cập vào capability statement
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/fhir/Patient/**").hasAuthority("SCOPE_patient/*.read")
.requestMatchers("/fhir/Observation/**").hasAuthority("SCOPE_patient/*.read")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.failureUrl("/login?error")
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
private Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<String> scopes = jwt.getClaimAsStringList("scope");
if (scopes == null) {
return Collections.emptyList();
}
return scopes.stream()
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
});
return converter;
}
@Bean
public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) {
return JwtDecoders.fromIssuerLocation(properties.getJwt().getIssuerUri());
}
}
Tích hợp OAuth2 với HAPI FHIR Client
1. Cấu hình HAPI FHIR Client với OAuth2
@Configuration
public class FhirClientConfig {
@Bean
public FhirContext fhirContext() {
return FhirContext.forR5();
}
@Bean
public IGenericClient fhirClient(FhirContext fhirContext, OAuth2AuthorizedClientService clientService) {
// Tạo client kết nối đến FHIR server
IGenericClient client = fhirContext.newRestfulGenericClient("https://fhir.example.com/fhir");
// Đăng ký interceptor để xử lý OAuth2 token
client.registerInterceptor(new OAuth2ClientCredentialsInterceptor(clientService));
return client;
}
// Custom interceptor cho OAuth2 Client Credentials flow
public class OAuth2ClientCredentialsInterceptor implements IClientInterceptor {
private final OAuth2AuthorizedClientService clientService;
public OAuth2ClientCredentialsInterceptor(OAuth2AuthorizedClientService clientService) {
this.clientService = clientService;
}
@Override
public void interceptRequest(IHttpRequest request) {
// Lấy thông tin client hiện tại
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient(
"fhir-client", authentication.getName());
if (authorizedClient != null) {
String accessToken = authorizedClient.getAccessToken().getTokenValue();
request.addHeader("Authorization", "Bearer " + accessToken);
}
}
}
@Override
public void interceptResponse(IHttpResponse response) {
// Không cần xử lý response
}
}
}
2. Service sử dụng OAuth2 Client
@Service
public class PatientService {
private final IGenericClient fhirClient;
@Autowired
public PatientService(IGenericClient fhirClient) {
this.fhirClient = fhirClient;
}
// Lấy thông tin bệnh nhân theo ID
public Patient getPatient(String id) {
return fhirClient.read()
.resource(Patient.class)
.withId(id)
.execute();
}
// Lấy danh sách bệnh nhân theo tham số tìm kiếm
public List<Patient> searchPatients(String name, int count) {
Bundle results = fhirClient.search()
.forResource(Patient.class)
.where(Patient.NAME.matches().value(name))
.count(count)
.returnBundle(Bundle.class)
.execute();
return results.getEntry().stream()
.map(entry -> (Patient) entry.getResource())
.collect(Collectors.toList());
}
// Tạo bệnh nhân mới
public MethodOutcome createPatient(Patient patient) {
return fhirClient.create()
.resource(patient)
.execute();
}
}
Triển khai SMART on FHIR Launch
SMART on FHIR định nghĩa một flow launch đặc biệt, nơi EHR có thể khởi chạy ứng dụng bên ngoài và cung cấp context như thông tin bệnh nhân.
1. Controller xử lý SMART Launch Sequence
@Controller
public class SmartLaunchController {
private final OAuth2AuthorizedClientService clientService;
private final FhirContext fhirContext;
@Autowired
public SmartLaunchController(OAuth2AuthorizedClientService clientService, FhirContext fhirContext) {
this.clientService = clientService;
this.fhirContext = fhirContext;
}
@GetMapping("/smart/launch")
public String handleSmartLaunch(
@RequestParam("iss") String issuer,
@RequestParam(value = "launch", required = false) String launchToken,
HttpServletRequest request) {
// Lưu thông tin issuer và launch token vào session
HttpSession session = request.getSession();
session.setAttribute("smart.issuer", issuer);
if (launchToken != null) {
session.setAttribute("smart.launch", launchToken);
}
// Chuyển hướng đến trang OAuth2 authorization
return "redirect:/oauth2/authorization/fhir-client";
}
@GetMapping("/smart/callback")
public String handleCallback(Authentication authentication, HttpServletRequest request) {
// Lấy context từ token JWT
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken.getName());
// Trích xuất thông tin từ token
String accessToken = authorizedClient.getAccessToken().getTokenValue();
JWT jwt = JWTParser.parse(accessToken);
// Lấy patient ID từ token (nếu có)
String patientId = jwt.getJWTClaimsSet().getStringClaim("patient");
if (patientId != null) {
// Lưu patient ID vào session để sử dụng sau này
request.getSession().setAttribute("smart.patient", patientId);
// Chuyển hướng đến trang patient detail
return "redirect:/patient/" + patientId;
}
// Nếu không có patient ID, chuyển đến dashboard chung
return "redirect:/dashboard";
}
}
2. Tạo SmartContextService để quản lý SMART context
@Service
public class SmartContextService {
private final IGenericClient fhirClient;
public SmartContextService(IGenericClient fhirClient) {
this.fhirClient = fhirClient;
}
// Lấy thông tin bệnh nhân từ context
public Patient getCurrentPatient(HttpSession session) {
String patientId = (String) session.getAttribute("smart.patient");
if (patientId != null) {
return fhirClient.read()
.resource(Patient.class)
.withId(patientId)
.execute();
}
return null;
}
// Lấy thông tin bác sĩ từ context
public Practitioner getCurrentPractitioner(HttpSession session) {
String practitionerId = (String) session.getAttribute("smart.practitioner");
if (practitionerId != null) {
return fhirClient.read()
.resource(Practitioner.class)
.withId(practitionerId)
.execute();
}
return null;
}
// Lấy thông tin encounter từ context
public Encounter getCurrentEncounter(HttpSession session) {
String encounterId = (String) session.getAttribute("smart.encounter");
if (encounterId != null) {
return fhirClient.read()
.resource(Encounter.class)
.withId(encounterId)
.execute();
}
return null;
}
// Kiểm tra quyền hạn của người dùng hiện tại
public boolean hasScope(Authentication authentication, String scope) {
if (authentication == null) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("SCOPE_" + scope));
}
}
Triển khai HAPI FHIR Server với OAuth2
1. Tạo RestfulServer với OAuth2 integration
@Component
public class FhirRestfulServer extends RestfulServer {
@Autowired
public FhirRestfulServer(FhirContext fhirContext,
List<IResourceProvider> resourceProviders,
RequestMappingInfoHandlerMapping handlerMapping) {
super(fhirContext);
// Đăng ký resource providers
setResourceProviders(resourceProviders);
// Cấu hình response encoding
setDefaultResponseEncoding(EncodingEnum.JSON);
// Đăng ký interceptor
registerInterceptor(new OAuth2AuthorizationInterceptor());
// Thêm CORS support
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
CorsInterceptor corsInterceptor = new CorsInterceptor(config);
registerInterceptor(corsInterceptor);
// Thêm logging
LoggingInterceptor loggingInterceptor = new LoggingInterceptor();
loggingInterceptor.setLoggerName("fhir.access");
loggingInterceptor.setMessageFormat(
"Path[${servletPath}] Source[${requestHeader.x-forwarded-for}] " +
"Operation[${operationType} ${operationName} ${idOrResourceName}] " +
"UA[${requestHeader.user-agent}] Params[${requestParameters}] " +
"ResponseEncoding[${responseEncodingNoDefault}]");
registerInterceptor(loggingInterceptor);
}
// OAuth2 Authorization Interceptor
private class OAuth2AuthorizationInterceptor extends InterceptorAdapter {
@Override
public boolean incomingRequestPostProcessed(RequestDetails theRequestDetails, HttpServletRequest theRequest, HttpServletResponse theResponse) throws AuthenticationException {
// Kiểm tra xem đây có phải là metadata request không (luôn cho phép)
if (theRequestDetails.getRequestPath().equals("metadata")) {
return true;
}
// Lấy JWT token từ Authorization header
String authHeader = theRequest.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
unauthorized(theResponse, "No authorization token provided");
return false;
}
String token = authHeader.substring(7);
try {
// Verify token (trong thực tế, sử dụng JwtDecoder của Spring)
JWT jwt = JWTParser.parse(token);
// Lấy các scopes từ token
List<String> scopes = jwt.getJWTClaimsSet().getStringListClaim("scope");
// Kiểm tra quyền hạn với resource và operation
String resourceType = theRequestDetails.getResourceName();
RestOperationTypeEnum operation = theRequestDetails.getRestOperationType();
boolean hasPermission = false;
// Kiểm tra scope phù hợp với resource type và operation
if (resourceType != null && operation != null) {
if (operation == RestOperationTypeEnum.READ || operation == RestOperationTypeEnum.VREAD
|| operation == RestOperationTypeEnum.SEARCH_TYPE) {
hasPermission = scopes.contains("patient/" + resourceType + ".read")
|| scopes.contains("patient/*." + "read")
|| scopes.contains("patient/" + resourceType + ".*")
|| scopes.contains("patient/*.*");
} else if (operation == RestOperationTypeEnum.CREATE || operation == RestOperationTypeEnum.UPDATE
|| operation == RestOperationTypeEnum.DELETE) {
hasPermission = scopes.contains("patient/" + resourceType + ".write")
|| scopes.contains("patient/*." + "write")
|| scopes.contains("patient/" + resourceType + ".*")
|| scopes.contains("patient/*.*");
}
}
if (!hasPermission) {
unauthorized(theResponse, "Insufficient permissions to access " + resourceType);
return false;
}
// Kiểm tra thông tin patient context nếu có compartment restriction
String patientId = jwt.getJWTClaimsSet().getStringClaim("patient");
if (patientId != null && resourceType != null) {
// Lưu patientId vào request attribute để sử dụng trong ResourceProvider
theRequest.setAttribute("smart.patientId", patientId);
}
return true;
} catch (Exception e) {
unauthorized(theResponse, "Invalid token: " + e.getMessage());
return false;
}
}
private void unauthorized(HttpServletResponse response, String message) throws AuthenticationException {
throw new AuthenticationException(message);
}
}
}
2. PatientResourceProvider với bảo mật OAuth2
@Component
public class PatientResourceProvider implements IResourceProvider {
private final IFhirResourceDao<Patient> patientDao;
@Autowired
public PatientResourceProvider(IFhirResourceDao<Patient> patientDao) {
this.patientDao = patientDao;
}
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Read
public Patient read(@IdParam IdType theId, RequestDetails theRequestDetails) {
// Kiểm tra compartment restriction nếu có
String contextPatientId = (String) theRequestDetails.getServletRequest().getAttribute("smart.patientId");
if (contextPatientId != null && !theId.getIdPart().equals(contextPatientId)) {
// Chỉ cho phép truy cập vào patient trong context
throw new ForbiddenOperationException("Access denied to patient outside of current context");
}
return patientDao.read(theId);
}
@Search
public IBundleProvider search(
@OptionalParam(name = Patient.SP_FAMILY) StringParam familyName,
@OptionalParam(name = Patient.SP_GIVEN) StringParam givenName,
@OptionalParam(name = Patient.SP_BIRTHDATE) DateParam birthDate,
RequestDetails theRequestDetails) {
SearchParameterMap params = new SearchParameterMap();
if (familyName != null) {
params.add(Patient.SP_FAMILY, familyName);
}
if (givenName != null) {
params.add(Patient.SP_GIVEN, givenName);
}
if (birthDate != null) {
params.add(Patient.SP_BIRTHDATE, birthDate);
}
// Kiểm tra compartment restriction nếu có
String contextPatientId = (String) theRequestDetails.getServletRequest().getAttribute("smart.patientId");
if (contextPatientId != null) {
// Chỉ tìm kiếm patient trong context
params.add(Patient.SP_RES_ID, new TokenParam(contextPatientId));
}
return patientDao.search(params);
}
@Create
public MethodOutcome create(@ResourceParam Patient patient, RequestDetails theRequestDetails) {
// Kiểm tra compartment restriction nếu có
String contextPatientId = (String) theRequestDetails.getServletRequest().getAttribute("smart.patientId");
if (contextPatientId != null && patient.getIdElement().hasIdPart() &&
!patient.getIdElement().getIdPart().equals(contextPatientId)) {
throw new ForbiddenOperationException("Cannot create patient outside of current context");
}
return patientDao.create(patient);
}
@Update
public MethodOutcome update(@IdParam IdType theId, @ResourceParam Patient patient, RequestDetails theRequestDetails) {
// Kiểm tra compartment restriction nếu có
String contextPatientId = (String) theRequestDetails.getServletRequest().getAttribute("smart.patientId");
if (contextPatientId != null && !theId.getIdPart().equals(contextPatientId)) {
throw new ForbiddenOperationException("Cannot update patient outside of current context");
}
patient.setId(theId);
return patientDao.update(patient);
}
@Delete
public MethodOutcome delete(@IdParam IdType theId, RequestDetails theRequestDetails) {
// Kiểm tra compartment restriction nếu có
String contextPatientId = (String) theRequestDetails.getServletRequest().getAttribute("smart.patientId");
if (contextPatientId != null && !theId.getIdPart().equals(contextPatientId)) {
throw new ForbiddenOperationException("Cannot delete patient outside of current context");
}
MethodOutcome outcome = new MethodOutcome();
outcome.setId(theId);
patientDao.delete(theId);
return outcome;
}
}
Cấu hình và triển khai Capability Statement
Capability Statement là một tài nguyên quan trọng trong FHIR, mô tả khả năng của server bao gồm cả thông tin bảo mật:
@Component
public class OAuth2CapabilityStatementProvider implements IServerConformanceProvider<CapabilityStatement> {
private final FhirContext fhirContext;
@Autowired
public OAuth2CapabilityStatementProvider(FhirContext fhirContext) {
this.fhirContext = fhirContext;
}
@Override
public CapabilityStatement getServerConformance(HttpServletRequest request) {
CapabilityStatement capabilityStatement = new CapabilityStatement();
// Thông tin cơ bản
capabilityStatement.setStatus(Enumerations.PublicationStatus.ACTIVE);
capabilityStatement.setDate(new Date());
capabilityStatement.setPublisher("Example Healthcare Organization");
capabilityStatement.setKind(CapabilityStatement.CapabilityStatementKind.INSTANCE);
// Thông tin phiên bản FHIR
capabilityStatement.setFhirVersion(Enumerations.FHIRVersion._5_0_0);
capabilityStatement.setFormat(List.of(new CodeType("json"), new CodeType("xml")));
// Thông tin bảo mật
CapabilityStatement.CapabilityStatementRestSecurityComponent security = new CapabilityStatement.CapabilityStatementRestSecurityComponent();
// Thêm SMART on FHIR extension
Extension smartExtension = new Extension();
smartExtension.setUrl("http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris");
// Thêm các endpoint OAuth2
smartExtension.addExtension()
.setUrl("authorize")
.setValue(new UriType("https://auth.example.com/oauth2/authorize"));
smartExtension.addExtension()
.setUrl("token")
.setValue(new UriType("https://auth.example.com/oauth2/token"));
smartExtension.addExtension()
.setUrl("register")
.setValue(new UriType("https://auth.example.com/oauth2/register"));
security.addExtension(smartExtension);
// Thêm thông tin service
CodeableConcept serviceCC = new CodeableConcept();
serviceCC.addCoding()
.setSystem("http://terminology.hl7.org/CodeSystem/restful-security-service")
.setCode("SMART-on-FHIR")
.setDisplay("SMART on FHIR");
security.setService(List.of(serviceCC));
// Thêm thông tin mô tả bảo mật
security.setDescription("OAuth2 using SMART-on-FHIR profile");
// Thêm resource component cho mỗi loại resource được hỗ trợ
CapabilityStatement.CapabilityStatementRestComponent rest = new CapabilityStatement.CapabilityStatementRestComponent();
rest.setMode(CapabilityStatement.RestfulCapabilityMode.SERVER);
rest.setSecurity(security);
// Thêm Patient resource
CapabilityStatement.CapabilityStatementRestResourceComponent patientResource = new CapabilityStatement.CapabilityStatementRestResourceComponent();
patientResource.setType("Patient");
patientResource.setProfile("http://hl7.org/fhir/StructureDefinition/Patient");
// Thêm các tương tác được hỗ trợ
patientResource.addInteraction()
.setCode(CapabilityStatement.TypeRestfulInteraction.READ)
.setDocumentation("Đọc thông tin bệnh nhân theo ID");
patientResource.addInteraction()
.setCode(CapabilityStatement.TypeRestfulInteraction.SEARCH_TYPE)
.setDocumentation("Tìm kiếm bệnh nhân theo các tiêu chí");
patientResource.addInteraction()
.setCode(CapabilityStatement.TypeRestfulInteraction.CREATE)
.setDocumentation("Tạo bệnh nhân mới");
patientResource.addInteraction()
.setCode(CapabilityStatement.TypeRestfulInteraction.UPDATE)
.setDocumentation("Cập nhật thông tin bệnh nhân");
patientResource.addInteraction()
.setCode(CapabilityStatement.TypeRestfulInteraction.DELETE)
.setDocumentation("Xóa bệnh nhân");
// Thêm search params
patientResource.addSearchParam()
.setName("family")
.setType(Enumerations.SearchParamType.STRING)
.setDocumentation("Tìm kiếm theo họ");
patientResource.addSearchParam()
.setName("given")
.setType(Enumerations.SearchParamType.STRING)
.setDocumentation("Tìm kiếm theo tên");
patientResource.addSearchParam()
.setName("birthdate")
.setType(Enumerations.SearchParamType.DATE)
.setDocumentation("Tìm kiếm theo ngày sinh");
rest.addResource(patientResource);
// Thêm tương tự cho các resource type khác (Observation, Encounter, etc.)
capabilityStatement.addRest(rest);
return capabilityStatement;
}
}
Cấu hình file Properties cho OAuth2
Để dễ dàng cấu hình và triển khai trên nhiều môi trường, bạn nên sử dụng các file properties/yaml:
spring:
security:
oauth2:
client:
registration:
fhir-client:
client-id: ${OAUTH2_CLIENT_ID:fhir-client}
client-secret: ${OAUTH2_CLIENT_SECRET:your-client-secret}
authorization-grant-type: authorization_code
redirect-uri: ${OAUTH2_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}
scope: ${OAUTH2_SCOPES:launch/patient,patient/*.read,patient/*.write}
provider:
fhir-client:
authorization-uri: ${OAUTH2_AUTH_URI:https://auth.example.com/oauth2/authorize}
token-uri: ${OAUTH2_TOKEN_URI:https://auth.example.com/oauth2/token}
jwk-set-uri: ${OAUTH2_JWK_URI:https://auth.example.com/.well-known/jwks.json}
user-info-uri: ${OAUTH2_USERINFO_URI:https://auth.example.com/userinfo}
user-name-attribute: sub
resourceserver:
jwt:
issuer-uri: ${OAUTH2_ISSUER_URI:https://auth.example.com}
jwk-set-uri: ${OAUTH2_JWK_URI:https://auth.example.com/.well-known/jwks.json}
# Cấu hình HAPI FHIR
hapi:
fhir:
server:
path: /fhir
address: ${FHIR_SERVER_ADDRESS:http://localhost:8080/fhir}
rest:
server-name: HAPI FHIR R5 Server
server-version: 6.4.0
implementation-description: HAPI FHIR R5 Server with OAuth2
default-page-size: 20
max-page-size: 200
default-response-encoding: json
validation:
enabled: true
request-only: false
server-mode: ENABLED
oauth2:
enabled: true
introspection:
url: ${OAUTH2_INTROSPECT_URL:https://auth.example.com/oauth2/introspect}
client-id: ${OAUTH2_RESOURCE_ID:fhir-resource-server}
client-secret: ${OAUTH2_RESOURCE_SECRET:resource-server-secret}
Tích hợp với Keycloak cho SMART on FHIR
Keycloak là một giải pháp Identity và Access Management mã nguồn mở phổ biến cho triển khai SMART on FHIR. Dưới đây là cách tích hợp Keycloak với HAPI FHIR:
1. Cấu hình Keycloak Realm và Client
Đầu tiên, bạn cần thiết lập Keycloak:
Tạo Realm "healthcare"
Tạo Client "fhir-client" (đây là ứng dụng của bạn)
Tạo Client "fhir-resource-server" (đây là FHIR server)
Tạo các Roles và SMART on FHIR Scopes
2. Cấu hình KeycloakConfig trong ứng dụng
@Configuration
public class KeycloakConfig {
@Value("${keycloak.auth-server-url}")
private String keycloakServerUrl;
@Value("${keycloak.realm}")
private String realm;
@Bean
public KeycloakClientRequestFactory keycloakClientRequestFactory() {
return new KeycloakClientRequestFactory();
}
@Bean
public KeycloakRestTemplate keycloakRestTemplate(KeycloakClientRequestFactory factory) {
return new KeycloakRestTemplate(factory);
}
@Bean
public PolicyEnforcer policyEnforcer() {
String configPath = "keycloak-policy-enforcer.json";
try (InputStream is = getClass().getClassLoader().getResourceAsStream(configPath)) {
return PolicyEnforcer.builder()
.configInputStream(is)
.build();
} catch (IOException e) {
throw new RuntimeException("Failed to load policy enforcer configuration", e);
}
}
@Bean
public FilterRegistrationBean<KeycloakAuthenticationFilter> keycloakAuthenticationFilter() {
FilterRegistrationBean<KeycloakAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new KeycloakAuthenticationFilter());
registrationBean.addUrlPatterns("/fhir/*");
registrationBean.setOrder(1);
return registrationBean;
}
// Cấu hình KeycloakAuthenticationProvider
@Bean
public KeycloakAuthenticationProvider keycloakAuthenticationProvider() {
KeycloakAuthenticationProvider provider = new KeycloakAuthenticationProvider();
provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
return provider;
}
// Cấu hình SecurityAdapter
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http
.csrf().disable()
.authorizeExchange()
.pathMatchers("/fhir/metadata").permitAll()
.pathMatchers("/fhir/**").authenticated()
.anyExchange().permitAll()
.and()
.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(keycloakJwtAuthenticationConverter());
return http.build();
}
private Converter<Jwt, AbstractAuthenticationToken> keycloakJwtAuthenticationConverter() {
return new ReactiveJwtAuthenticationConverterAdapter(new KeycloakJwtAuthenticationConverter());
}
// Cấu hình adapter cho Keycloak
@Bean
public KeycloakConfigResolver keycloakConfigResolver() {
return new KeycloakSpringBootConfigResolver();
}
}
3. Mapper cho thông tin SMART on FHIR
Keycloak hỗ trợ custom protocol mapper để thêm thông tin SMART context vào token:
public class SmartOnFhirContextMapper extends AbstractOIDCProtocolMapper implements OIDCAccessTokenMapper {
private static final String PROVIDER_ID = "smart-on-fhir-context-mapper";
private static final String PATIENT_ID = "patient";
private static final String ENCOUNTER_ID = "encounter";
@Override
public String getDisplayType() {
return "SMART on FHIR Context";
}
@Override
public String getProtocol() {
return OIDCLoginProtocol.LOGIN_PROTOCOL;
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, KeycloakSession session,
UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
// Lấy thông tin patient từ launch context
String launchContext = userSession.getNote("launch_context");
if (launchContext != null) {
try {
JSONObject context = new JSONObject(launchContext);
if (context.has(PATIENT_ID)) {
token.getOtherClaims().put(PATIENT_ID, context.getString(PATIENT_ID));
}
if (context.has(ENCOUNTER_ID)) {
token.getOtherClaims().put(ENCOUNTER_ID, context.getString(ENCOUNTER_ID));
}
} catch (Exception e) {
// Xử lý lỗi
}
}
}
}
Sử dụng SMART on FHIR App Launcher
Để cung cấp một môi trường test hoàn chỉnh, bạn có thể triển khai SMART App Launcher:
@Controller
@RequestMapping("/smart-launcher")
public class SmartAppLauncherController {
private final IFhirResourceDao<Patient> patientDao;
private final IFhirResourceDao<Encounter> encounterDao;
private final FhirContext fhirContext;
@Autowired
public SmartAppLauncherController(IFhirResourceDao<Patient> patientDao,
IFhirResourceDao<Encounter> encounterDao,
FhirContext fhirContext) {
this.patientDao = patientDao;
this.encounterDao = encounterDao;
this.fhirContext = fhirContext;
}
@GetMapping
public String showLauncher(Model model) {
// Lấy danh sách patient để hiển thị
IBundleProvider patientBundle = patientDao.search(new SearchParameterMap());
List<Patient> patients = patientBundle.getResources(0, 10);
model.addAttribute("patients", patients);
// Lấy danh sách encounter để hiển thị
IBundleProvider encounterBundle = encounterDao.search(new SearchParameterMap());
List<Encounter> encounters = encounterBundle.getResources(0, 10);
model.addAttribute("encounters", encounters);
// Thêm danh sách ứng dụng SMART
model.addAttribute("apps", getSmartApps());
return "smart-launcher";
}
@PostMapping("/launch")
public String launchApp(@RequestParam String appUrl,
@RequestParam(required = false) String patientId,
@RequestParam(required = false) String encounterId,
HttpServletRequest request) {
// Tạo launch context
String launchToken = generateLaunchToken(patientId, encounterId, request);
// Tạo redirect URL
String redirectUrl = appUrl + "?iss=" + getServerBaseUrl(request) +
"&launch=" + launchToken;
return "redirect:" + redirectUrl;
}
private String generateLaunchToken(String patientId, String encounterId, HttpServletRequest request) {
// Tạo context chứa thông tin patient và encounter
JSONObject launchContext = new JSONObject();
if (patientId != null && !patientId.isEmpty()) {
launchContext.put("patient", patientId);
}
if (encounterId != null && !encounterId.isEmpty()) {
launchContext.put("encounter", encounterId);
}
// Lưu context vào session để sử dụng sau này
HttpSession session = request.getSession();
String launchToken = UUID.randomUUID().toString();
session.setAttribute("smart_launch_" + launchToken, launchContext.toString());
return launchToken;
}
private String getServerBaseUrl(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int port = request.getServerPort();
String contextPath = request.getContextPath();
return scheme + "://" + serverName + ":" + port + contextPath;
}
private List<Map<String, String>> getSmartApps() {
List<Map<String, String>> apps = new ArrayList<>();
// Thêm một số ứng dụng SMART mẫu
Map<String, String> growthChart = new HashMap<>();
growthChart.put("name", "Growth Chart");
growthChart.put("url", "https://smart.hl7.org/growth-chart-app/launch.html");
growthChart.put("description", "Pediatric Growth Chart Application");
apps.add(growthChart);
Map<String, String> bpCentiles = new HashMap<>();
bpCentiles.put("name", "BP Centiles");
bpCentiles.put("url", "https://smart.hl7.org/bp-centiles-app/launch.html");
bpCentiles.put("description", "Blood Pressure Percentiles Application");
apps.add(bpCentiles);
Map<String, String> cardiacRisk = new HashMap<>();
cardiacRisk.put("name", "Cardiac Risk");
cardiacRisk.put("url", "https://smart.hl7.org/cardiac-risk-app/launch.html");
cardiacRisk.put("description", "Cardiac Risk Assessment Application");
apps.add(cardiacRisk);
return apps;
}
}
Lớp trung gian: OAuth2FhirClientContext
Để giúp quản lý context của FHIR client với OAuth2, chúng ta có thể tạo một lớp trung gian:
@Component
public class OAuth2FhirClientContext {
private final FhirContext fhirContext;
private final RestTemplateBuilder restTemplateBuilder;
private final OAuth2AuthorizedClientService clientService;
// ThreadLocal để lưu client cho mỗi thread
private final ThreadLocal<IGenericClient> threadLocalClient = new ThreadLocal<>();
@Autowired
public OAuth2FhirClientContext(FhirContext fhirContext,
RestTemplateBuilder restTemplateBuilder,
OAuth2AuthorizedClientService clientService) {
this.fhirContext = fhirContext;
this.restTemplateBuilder = restTemplateBuilder;
this.clientService = clientService;
}
// Lấy client cho người dùng hiện tại
public IGenericClient getClient(Authentication authentication, String serverBase) {
if (threadLocalClient.get() != null) {
return threadLocalClient.get();
}
IGenericClient client = fhirContext.newRestfulGenericClient(serverBase);
// Thêm interceptor để xử lý OAuth2 token
client.registerInterceptor(new OAuth2ClientInterceptor(authentication, clientService));
// Lưu client vào ThreadLocal
threadLocalClient.set(client);
return client;
}
// Xóa client sau khi sử dụng
public void clearClient() {
threadLocalClient.remove();
}
// Interceptor để thêm OAuth2 token vào request
private class OAuth2ClientInterceptor implements IClientInterceptor {
private final Authentication authentication;
private final OAuth2AuthorizedClientService clientService;
public OAuth2ClientInterceptor(Authentication authentication, OAuth2AuthorizedClientService clientService) {
this.authentication = authentication;
this.clientService = clientService;
}
@Override
public void interceptRequest(IHttpRequest request) {
if (authentication != null && authentication.isAuthenticated()) {
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
authentication.getName());
if (authorizedClient != null) {
String accessToken = authorizedClient.getAccessToken().getTokenValue();
request.addHeader("Authorization", "Bearer " + accessToken);
}
}
}
@Override
public void interceptResponse(IHttpResponse response) {
// Không cần xử lý response
}
}
}
Quản lý Refresh Token
Để xử lý trường hợp access token hết hạn, cần cài đặt cơ chế refresh token:
@Component
public class OAuth2TokenRefresher {
private final OAuth2AuthorizedClientService clientService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final OAuth2AuthorizedClientRepository authorizedClientRepository;
@Autowired
public OAuth2TokenRefresher(OAuth2AuthorizedClientService clientService,
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
this.clientService = clientService;
this.clientRegistrationRepository = clientRegistrationRepository;
this.authorizedClientRepository = authorizedClientRepository;
}
/**
* Kiểm tra và refresh token nếu cần
*/
public OAuth2AuthorizedClient checkAndRefreshToken(String clientRegistrationId, String principalName,
HttpServletRequest request, HttpServletResponse response) {
// Lấy thông tin client đã authorized
OAuth2AuthorizedClient authorizedClient = clientService.loadAuthorizedClient(
clientRegistrationId, principalName);
if (authorizedClient != null) {
// Kiểm tra xem token có sắp hết hạn không (còn < 5 phút)
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
Instant expiresAt = accessToken.getExpiresAt();
if (expiresAt != null && Instant.now().plusSeconds(300).isAfter(expiresAt)) {
// Token sắp hết hạn, tiến hành refresh
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
if (refreshToken != null) {
try {
// Lấy thông tin client registration
ClientRegistration clientRegistration = clientRegistrationRepository.findByRegistrationId(clientRegistrationId);
// Tạo authentication object
Authentication principal = new UsernamePasswordAuthenticationToken(principalName, "n/a");
// Refresh token
OAuth2AuthorizedClient refreshedClient = refreshAuthorizedClient(
authorizedClient, clientRegistration, refreshToken);
// Lưu client đã refresh
authorizedClientRepository.saveAuthorizedClient(
refreshedClient, principal, request, response);
return refreshedClient;
} catch (Exception e) {
// Xử lý lỗi khi refresh token
throw new OAuth2AuthenticationException(
new OAuth2Error("invalid_token", "Failed to refresh token", null), e);
}
}
}
}
return authorizedClient;
}
/**
* Refresh token và tạo OAuth2AuthorizedClient mới
*/
private OAuth2AuthorizedClient refreshAuthorizedClient(OAuth2AuthorizedClient client,
ClientRegistration registration,
OAuth2RefreshToken refreshToken) {
// Chuẩn bị request parameters
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
parameters.add(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue());
parameters.add(OAuth2ParameterNames.REFRESH_TOKEN, refreshToken.getTokenValue());
// Thêm client authentication nếu cần
if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(registration.getClientAuthenticationMethod())) {
// Basic Authentication
parameters.add(OAuth2ParameterNames.CLIENT_ID, registration.getClientId());
parameters.add(OAuth2ParameterNames.CLIENT_SECRET, registration.getClientSecret());
}
// Tạo request
RestTemplate restTemplate = new RestTemplate();
// Thêm HTTP Basic Auth nếu cần
if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(registration.getClientAuthenticationMethod())) {
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(
registration.getClientId(), registration.getClientSecret()));
}
// Thực hiện request
ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(
registration.getProviderDetails().getTokenUri(),
HttpMethod.POST,
new HttpEntity<>(parameters),
OAuth2AccessTokenResponse.class);
OAuth2AccessTokenResponse tokenResponse = response.getBody();
// Tạo OAuth2AuthorizedClient mới
OAuth2AccessToken newAccessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
tokenResponse.getAccessToken().getTokenValue(),
tokenResponse.getAccessToken().getIssuedAt(),
tokenResponse.getAccessToken().getExpiresAt(),
new HashSet<>(tokenResponse.getAccessToken().getScopes()));
OAuth2RefreshToken newRefreshToken = null;
if (tokenResponse.getRefreshToken() != null) {
newRefreshToken = tokenResponse.getRefreshToken();
} else {
// Giữ refresh token cũ nếu server không trả về refresh token mới
newRefreshToken = refreshToken;
}
return new OAuth2AuthorizedClient(
registration,
client.getPrincipalName(),
newAccessToken,
newRefreshToken);
}
}
Triển khai Exception Handling cho OAuth2
@ControllerAdvice
public class OAuth2ExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(OAuth2ExceptionHandler.class);
@ExceptionHandler(OAuth2AuthenticationException.class)
public ResponseEntity<Map<String, String>> handleOAuth2AuthenticationException(OAuth2AuthenticationException ex) {
logger.error("OAuth2 Authentication Exception", ex);
Map<String, String> error = new HashMap<>();
error.put("error", ex.getError().getErrorCode());
error.put("error_description", ex.getError().getDescription());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(ClientAuthorizationException.class)
public String handleClientAuthorizationException(ClientAuthorizationException ex,
HttpServletRequest request) {
logger.error("Client Authorization Exception", ex);
// Xóa token hiện tại và chuyển hướng đến trang login
request.getSession().invalidate();
return "redirect:/login";
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, String>> handleAccessDeniedException(AccessDeniedException ex) {
logger.error("Access Denied Exception", ex);
Map<String, String> error = new HashMap<>();
error.put("error", "access_denied");
error.put("error_description", "Insufficient permissions to access the requested resource");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
}
Kết luận
HAPI FHIR kết hợp với OAuth2 và SMART on FHIR tạo nên nền tảng mạnh mẽ cho việc xây dựng các ứng dụng y tế hiện đại, đảm bảo tính bảo mật và interoperability. Bài viết đã trình bày chi tiết về:
Cấu hình OAuth2 trong Spring Boot: Triển khai các thành phần cần thiết để tích hợp OAuth2 với Spring Security
HAPI FHIR Client với OAuth2: Tạo client có khả năng xác thực qua OAuth2
SMART on FHIR Launch Context: Xử lý và duy trì context thông qua quá trình launch
FHIR Server bảo mật bằng OAuth2: Triển khai server với các cơ chế xác thực và ủy quyền
Capability Statement với thông tin bảo mật: Mô tả khả năng bảo mật của server
Tích hợp Keycloak: Sử dụng Keycloak như một Identity Provider cho SMART on FHIR
Xử lý Refresh Token: Tự động làm mới token hết hạn
Exception Handling: Xử lý các lỗi liên quan đến OAuth2
Với kiến trúc này, bạn có thể xây dựng các ứng dụng y tế tuân thủ các tiêu chuẩn mới nhất, đảm bảo bảo mật dữ liệu y tế nhạy cảm, đồng thời cung cấp trải nghiệm người dùng liền mạch.
Tài nguyên bổ sung
Last updated