Commit 09bbd610 authored by Phùng Quốc Toàn's avatar Phùng Quốc Toàn

Merge branch 'course/issue-and-verify-jwt-token' into 'master'

feat: implement JWT refresh and logout functionality, add Redis caching support

See merge request !2
parents 1d64944a d53a2ff2
...@@ -66,6 +66,14 @@ ...@@ -66,6 +66,14 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
......
...@@ -5,8 +5,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; ...@@ -5,8 +5,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication @SpringBootApplication
public class IdentityServiceApplication { public class IdentityServiceApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(IdentityServiceApplication.class, args); SpringApplication.run(IdentityServiceApplication.class, args);
} }
} }
package com.devteria.identityservice.configuration;
import com.devteria.identityservice.dto.request.IntrospectRequest;
import com.devteria.identityservice.service.AuthenticationService;
import com.nimbusds.jose.JOSEException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.stereotype.Component;
import javax.crypto.spec.SecretKeySpec;
import java.text.ParseException;
import java.util.Objects;
@Component
public class CustomJwtDecoder implements JwtDecoder {
@Value("${jwt.signer-key}")
private String signerKey;
@Autowired
private AuthenticationService authenticationService;
private NimbusJwtDecoder nimbusJwtDecoder = null;
@Override
public Jwt decode(String token) throws JwtException {
try {
var response = authenticationService.introspect(IntrospectRequest.builder()
.token(token)
.build());
if (!response.isValid())
throw new JwtException("Token invalid");
} catch (JOSEException | ParseException e) {
throw new JwtException(e.getMessage());
}
if (Objects.isNull(nimbusJwtDecoder)) {
SecretKeySpec secretKeySpec = new SecretKeySpec(signerKey.getBytes(), "HS512");
nimbusJwtDecoder = NimbusJwtDecoder
.withSecretKey(secretKeySpec)
.macAlgorithm(MacAlgorithm.HS512)
.build();
}
return nimbusJwtDecoder.decode(token);
}
}
\ No newline at end of file
...@@ -8,9 +8,11 @@ import jakarta.servlet.http.HttpServletRequest; ...@@ -8,9 +8,11 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override @Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
......
package com.devteria.identityservice.configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${redis.host}")
private String redisHost;
@Value("${redis.port}")
private int redisPort;
@Value("${redis.password:}")
private String redisPassword;
@Value("${redis.time-to-live}")
private long timeToLive;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
redisConfig.setHostName(redisHost);
redisConfig.setPort(redisPort);
if (!redisPassword.isEmpty()) {
redisConfig.setPassword(redisPassword);
}
return new LettuceConnectionFactory(redisConfig);
}
@Bean
@Primary
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(stringRedisSerializer());
template.setValueSerializer(genericJackson2JsonRedisSerializer());
template.setHashKeySerializer(stringRedisSerializer());
template.setHashValueSerializer(genericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisSerializer stringRedisSerializer() {
return new StringRedisSerializer();
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
@Bean
public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
return new GenericJackson2JsonRedisSerializer(objectMapper());
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(timeToLive))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfig)
.build();
}
}
package com.devteria.identityservice.configuration; package com.devteria.identityservice.configuration;
import org.springframework.beans.factory.annotation.Value; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
...@@ -10,24 +10,19 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe ...@@ -10,24 +10,19 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import javax.crypto.spec.SecretKeySpec;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final String[] PUBLIC_ENDPOINTS = new String[]{"/auth/token", "/auth/introspect", "/users"}; private final String[] PUBLIC_ENDPOINTS = new String[]{"/auth/token", "/auth/introspect", "/users", "/auth/logout", "/auth/refresh-token"};
@Value("${jwt.signerKey}") CustomJwtDecoder customJwtDecoder;
private String signerKey;
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
...@@ -38,7 +33,7 @@ public class SecurityConfig { ...@@ -38,7 +33,7 @@ public class SecurityConfig {
httpSecurity.oauth2ResourceServer(oauth2 -> httpSecurity.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwtConfigurer -> oauth2.jwt(jwtConfigurer ->
jwtConfigurer.decoder(jwtDecoder()) jwtConfigurer.decoder(customJwtDecoder)
.jwtAuthenticationConverter(jwtConverter())) .jwtAuthenticationConverter(jwtConverter()))
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())); .authenticationEntryPoint(new JwtAuthenticationEntryPoint()));
...@@ -50,7 +45,7 @@ public class SecurityConfig { ...@@ -50,7 +45,7 @@ public class SecurityConfig {
@Bean @Bean
JwtAuthenticationConverter jwtConverter() { JwtAuthenticationConverter jwtConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
...@@ -58,12 +53,6 @@ public class SecurityConfig { ...@@ -58,12 +53,6 @@ public class SecurityConfig {
return jwtAuthenticationConverter; return jwtAuthenticationConverter;
} }
@Bean
JwtDecoder jwtDecoder() {
SecretKeySpec secretKeySpec = new SecretKeySpec(signerKey.getBytes(), "HS512");
return NimbusJwtDecoder.withSecretKey(secretKeySpec).macAlgorithm(MacAlgorithm.HS512).build();
}
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10); return new BCryptPasswordEncoder(10);
......
package com.devteria.identityservice.controller; package com.devteria.identityservice.controller;
import com.devteria.identityservice.dto.request.ApiResponse; import com.devteria.identityservice.dto.request.*;
import com.devteria.identityservice.dto.request.AuthenticationRequest;
import com.devteria.identityservice.dto.request.IntrospectRequest;
import com.devteria.identityservice.dto.response.AuthenticationResponse; import com.devteria.identityservice.dto.response.AuthenticationResponse;
import com.devteria.identityservice.dto.response.IntrospectResponse; import com.devteria.identityservice.dto.response.IntrospectResponse;
import com.devteria.identityservice.service.AuthenticationService; import com.devteria.identityservice.service.AuthenticationService;
...@@ -25,13 +23,21 @@ public class AuthenticationController { ...@@ -25,13 +23,21 @@ public class AuthenticationController {
AuthenticationService authenticationService; AuthenticationService authenticationService;
@PostMapping("/token") @PostMapping("/token")
ApiResponse<AuthenticationResponse> authenticate(@RequestBody AuthenticationRequest request){ ApiResponse<AuthenticationResponse> authenticate(@RequestBody AuthenticationRequest request) {
var result = authenticationService.authenticate(request); var result = authenticationService.authenticate(request);
return ApiResponse.<AuthenticationResponse>builder() return ApiResponse.<AuthenticationResponse>builder()
.result(result) .result(result)
.build(); .build();
} }
@PostMapping("/refresh-token")
ApiResponse<AuthenticationResponse> refreshToken(@RequestBody RefreshTokenRequest request) throws ParseException, JOSEException {
var result = authenticationService.refreshToken(request);
return ApiResponse.<AuthenticationResponse>builder()
.result(result)
.build();
}
@PostMapping("/introspect") @PostMapping("/introspect")
ApiResponse<IntrospectResponse> authenticate(@RequestBody IntrospectRequest request) ApiResponse<IntrospectResponse> authenticate(@RequestBody IntrospectRequest request)
throws ParseException, JOSEException { throws ParseException, JOSEException {
...@@ -40,4 +46,12 @@ public class AuthenticationController { ...@@ -40,4 +46,12 @@ public class AuthenticationController {
.result(result) .result(result)
.build(); .build();
} }
@PostMapping("/logout")
ApiResponse<String> logout(@RequestBody LogoutRequest request) throws ParseException, JOSEException {
authenticationService.logout(request);
return ApiResponse.<String>builder()
.result("Logout successful")
.build();
}
} }
...@@ -34,8 +34,8 @@ public class PermissionController { ...@@ -34,8 +34,8 @@ public class PermissionController {
.build(); .build();
} }
@DeleteMapping @DeleteMapping("/{permission}")
ApiResponse<String> delete(@RequestParam String permission) { ApiResponse<String> delete(@PathVariable String permission) {
permissionService.delete(permission); permissionService.delete(permission);
return ApiResponse.<String>builder() return ApiResponse.<String>builder()
.result("Permission deleted successfully") .result("Permission deleted successfully")
......
package com.devteria.identityservice.controller;
import com.devteria.identityservice.dto.request.ApiResponse;
import com.devteria.identityservice.dto.request.RoleRequest;
import com.devteria.identityservice.dto.response.RoleResponse;
import com.devteria.identityservice.service.RoleService;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/roles")
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
@Slf4j
public class RoleController {
RoleService roleService;
@PostMapping
ApiResponse<RoleResponse> create(@RequestBody RoleRequest request) {
return ApiResponse.<RoleResponse>builder()
.result(roleService.create(request))
.build();
}
@GetMapping
ApiResponse<List<RoleResponse>> getAll() {
return ApiResponse.<List<RoleResponse>>builder()
.result(roleService.getAll())
.build();
}
@DeleteMapping("/{role}")
ApiResponse<String> delete(@PathVariable String role) {
roleService.delete(role);
return ApiResponse.<String>builder()
.result("Role deleted successfully")
.build();
}
}
package com.devteria.identityservice.dto.request;
import lombok.*;
import lombok.experimental.FieldDefaults;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class LogoutRequest {
String token;
}
\ No newline at end of file
package com.devteria.identityservice.dto.request;
import lombok.*;
import lombok.experimental.FieldDefaults;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class RefreshTokenRequest {
String token;
}
\ No newline at end of file
package com.devteria.identityservice.dto.request;
import lombok.*;
import lombok.experimental.FieldDefaults;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class RoleRequest {
String name;
String description;
Set<String> permissions;
}
...@@ -12,12 +12,14 @@ import java.time.LocalDate; ...@@ -12,12 +12,14 @@ import java.time.LocalDate;
@Builder @Builder
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
public class UserCreationRequest { public class UserCreationRequest {
@Size(min = 3,message = "USERNAME_INVALID") @Size(min = 3, message = "USERNAME_INVALID")
String username; String username;
@Size(min = 8, message = "INVALID_PASSWORD") @Size(min = 8, message = "INVALID_PASSWORD")
String password; String password;
String firstName; String firstName;
String lastName; String lastName;
// @DobConstraint(min = 16, message = "INVALID_DOB")
LocalDate dob; LocalDate dob;
} }
package com.devteria.identityservice.dto.request; package com.devteria.identityservice.dto.request;
import com.devteria.identityservice.validator.DobConstraint;
import lombok.*; import lombok.*;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
@Data @Data
@Builder @Builder
...@@ -14,5 +16,10 @@ public class UserUpdateRequest { ...@@ -14,5 +16,10 @@ public class UserUpdateRequest {
String password; String password;
String firstName; String firstName;
String lastName; String lastName;
@DobConstraint(min = 18, message = "INVALID_DOB")
LocalDate dob; LocalDate dob;
List<String> roles;
} }
package com.devteria.identityservice.dto.response; package com.devteria.identityservice.dto.response;
import lombok.*;
import lombok.experimental.FieldDefaults;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class PermissionResponse { public class PermissionResponse {
} String name;
String description;
}
\ No newline at end of file
package com.devteria.identityservice.dto.response;
import com.devteria.identityservice.entity.Permission;
import lombok.*;
import lombok.experimental.FieldDefaults;
import java.util.Set;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class RoleResponse {
String name;
String description;
Set<Permission> permissions;
}
\ No newline at end of file
package com.devteria.identityservice.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.*;
import lombok.experimental.FieldDefaults;
import java.util.Date;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
@Entity
public class InvalidatedToken {
@Id
String id;
Date expiryTime;
}
...@@ -9,11 +9,12 @@ public enum ErrorCode { ...@@ -9,11 +9,12 @@ public enum ErrorCode {
UNCATEGORIZED_EXCEPTION(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR), UNCATEGORIZED_EXCEPTION(9999, "Uncategorized error", HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_KEY(1001, "Uncategorized error", HttpStatus.BAD_REQUEST), INVALID_KEY(1001, "Uncategorized error", HttpStatus.BAD_REQUEST),
USER_EXISTED(1002, "User existed", HttpStatus.BAD_REQUEST), USER_EXISTED(1002, "User existed", HttpStatus.BAD_REQUEST),
USERNAME_INVALID(1003, "Username must be at least 3 characters", HttpStatus.BAD_REQUEST), USERNAME_INVALID(1003, "Username must be at least {min} characters", HttpStatus.BAD_REQUEST),
INVALID_PASSWORD(1004, "Password must be at least 8 characters", HttpStatus.BAD_REQUEST), INVALID_PASSWORD(1004, "Password must be at least {min} characters", HttpStatus.BAD_REQUEST),
USER_NOT_EXISTED(1005, "User not existed", HttpStatus.NOT_FOUND), USER_NOT_EXISTED(1005, "User not existed", HttpStatus.NOT_FOUND),
UNAUTHENTICATED(1006, "Unauthenticated", HttpStatus.UNAUTHORIZED), UNAUTHENTICATED(1006, "Unauthenticated", HttpStatus.UNAUTHORIZED),
UNAUTHORIZED(1007, "You do not have permission", HttpStatus.FORBIDDEN), UNAUTHORIZED(1007, "You do not have permission", HttpStatus.FORBIDDEN),
INVALID_DOB(1008, "Date of birth must be at least {min} years old", HttpStatus.BAD_REQUEST),
; ;
......
package com.devteria.identityservice.exception; package com.devteria.identityservice.exception;
import com.devteria.identityservice.dto.request.ApiResponse; import com.devteria.identityservice.dto.request.ApiResponse;
import jakarta.validation.ConstraintViolation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
...@@ -8,9 +9,13 @@ import org.springframework.web.bind.MethodArgumentNotValidException; ...@@ -8,9 +9,13 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.Map;
import java.util.Objects;
@ControllerAdvice @ControllerAdvice
@Slf4j @Slf4j
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final String MIN_ATTRIBUTE = "min";
@ExceptionHandler(value = Exception.class) @ExceptionHandler(value = Exception.class)
ResponseEntity<ApiResponse> handlingRuntimeException(RuntimeException exception) { ResponseEntity<ApiResponse> handlingRuntimeException(RuntimeException exception) {
...@@ -40,8 +45,13 @@ public class GlobalExceptionHandler { ...@@ -40,8 +45,13 @@ public class GlobalExceptionHandler {
ErrorCode errorCode = ErrorCode.INVALID_KEY; ErrorCode errorCode = ErrorCode.INVALID_KEY;
Map<String, Object> attributes = null;
try { try {
errorCode = ErrorCode.valueOf(enumKey); errorCode = ErrorCode.valueOf(enumKey);
var constraintViolation = exception.getBindingResult().getAllErrors().getFirst().unwrap(ConstraintViolation.class);
attributes = constraintViolation.getConstraintDescriptor().getAttributes();
log.info(attributes.toString());
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
} }
...@@ -49,7 +59,7 @@ public class GlobalExceptionHandler { ...@@ -49,7 +59,7 @@ public class GlobalExceptionHandler {
ApiResponse apiResponse = new ApiResponse(); ApiResponse apiResponse = new ApiResponse();
apiResponse.setCode(errorCode.getCode()); apiResponse.setCode(errorCode.getCode());
apiResponse.setMessage(errorCode.getMessage()); apiResponse.setMessage(Objects.nonNull(attributes) ? mapAttribute(errorCode.getMessage(), attributes) : errorCode.getMessage());
return ResponseEntity.badRequest().body(apiResponse); return ResponseEntity.badRequest().body(apiResponse);
} }
...@@ -60,4 +70,10 @@ public class GlobalExceptionHandler { ...@@ -60,4 +70,10 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(errorCode.getStatusCode()).body(ApiResponse.builder().code(errorCode.getCode()).message(errorCode.getMessage()).build()); return ResponseEntity.status(errorCode.getStatusCode()).body(ApiResponse.builder().code(errorCode.getCode()).message(errorCode.getMessage()).build());
} }
private String mapAttribute(String message, Map<String, Object> attributes) {
String minValue = attributes.get(MIN_ATTRIBUTE).toString();
return message.replace("{" + MIN_ATTRIBUTE + "}", minValue);
}
} }
package com.devteria.identityservice.mapper;
import com.devteria.identityservice.dto.request.RoleRequest;
import com.devteria.identityservice.dto.response.RoleResponse;
import com.devteria.identityservice.entity.Role;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface RoleMapper {
@Mapping(target = "permissions", ignore = true)
Role toRole(RoleRequest request);
RoleResponse toRoleResponse(Role role);
}
\ No newline at end of file
...@@ -5,6 +5,7 @@ import com.devteria.identityservice.dto.request.UserUpdateRequest; ...@@ -5,6 +5,7 @@ import com.devteria.identityservice.dto.request.UserUpdateRequest;
import com.devteria.identityservice.dto.response.UserResponse; import com.devteria.identityservice.dto.response.UserResponse;
import com.devteria.identityservice.entity.User; import com.devteria.identityservice.entity.User;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget; import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring") @Mapper(componentModel = "spring")
...@@ -13,5 +14,6 @@ public interface UserMapper { ...@@ -13,5 +14,6 @@ public interface UserMapper {
UserResponse toUserResponse(User user); UserResponse toUserResponse(User user);
@Mapping(target = "roles", ignore = true)
void updateUser(@MappingTarget User user, UserUpdateRequest request); void updateUser(@MappingTarget User user, UserUpdateRequest request);
} }
package com.devteria.identityservice.repository;
import com.devteria.identityservice.entity.InvalidatedToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface InvalidatedTokenRepository extends JpaRepository<InvalidatedToken, String> {
}
package com.devteria.identityservice.repository;
import com.devteria.identityservice.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RoleRepository extends JpaRepository<Role, String> {
}
...@@ -2,8 +2,11 @@ package com.devteria.identityservice.service; ...@@ -2,8 +2,11 @@ package com.devteria.identityservice.service;
import com.devteria.identityservice.dto.request.AuthenticationRequest; import com.devteria.identityservice.dto.request.AuthenticationRequest;
import com.devteria.identityservice.dto.request.IntrospectRequest; import com.devteria.identityservice.dto.request.IntrospectRequest;
import com.devteria.identityservice.dto.request.LogoutRequest;
import com.devteria.identityservice.dto.request.RefreshTokenRequest;
import com.devteria.identityservice.dto.response.AuthenticationResponse; import com.devteria.identityservice.dto.response.AuthenticationResponse;
import com.devteria.identityservice.dto.response.IntrospectResponse; import com.devteria.identityservice.dto.response.IntrospectResponse;
import com.devteria.identityservice.entity.InvalidatedToken;
import com.devteria.identityservice.entity.User; import com.devteria.identityservice.entity.User;
import com.devteria.identityservice.exception.AppException; import com.devteria.identityservice.exception.AppException;
import com.devteria.identityservice.exception.ErrorCode; import com.devteria.identityservice.exception.ErrorCode;
...@@ -19,42 +22,55 @@ import lombok.experimental.FieldDefaults; ...@@ -19,42 +22,55 @@ import lombok.experimental.FieldDefaults;
import lombok.experimental.NonFinal; import lombok.experimental.NonFinal;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.text.ParseException; import java.text.ParseException;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
import java.util.StringJoiner; import java.util.StringJoiner;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AuthenticationService { public class AuthenticationService {
private static final String TOKEN_KEY_PREFIX = "invalid_token:";
RedisTemplate<String, Object> redisTemplate;
UserRepository userRepository; UserRepository userRepository;
@NonFinal @NonFinal
@Value("${jwt.signerKey}") @Value("${jwt.signer-key}")
protected String SIGNER_KEY; protected String SIGNER_KEY;
@NonFinal
@Value("${jwt.expiration-time}")
protected Long EXPIRATION_TIME;
@NonFinal
@Value("${jwt.refresh-expiration-time}")
protected Long REFRESH_EXPIRATION_TIME;
public IntrospectResponse introspect(IntrospectRequest request) public IntrospectResponse introspect(IntrospectRequest request)
throws JOSEException, ParseException { throws JOSEException, ParseException {
var token = request.getToken(); var token = request.getToken();
JWSVerifier verifier = new MACVerifier(SIGNER_KEY.getBytes()); boolean isValid = true;
try {
SignedJWT signedJWT = SignedJWT.parse(token); verifyToken(token, false);
} catch (AppException a) {
Date expiryTime = signedJWT.getJWTClaimsSet().getExpirationTime(); isValid = false;
}
var verified = signedJWT.verify(verifier);
return IntrospectResponse.builder() return IntrospectResponse.builder()
.valid(verified && expiryTime.after(new Date())) .valid(isValid)
.build(); .build();
} }
public AuthenticationResponse authenticate(AuthenticationRequest request) { public AuthenticationResponse authenticate(AuthenticationRequest request) {
...@@ -83,8 +99,9 @@ public class AuthenticationService { ...@@ -83,8 +99,9 @@ public class AuthenticationService {
.issuer("demo_jwt") .issuer("demo_jwt")
.issueTime(new Date()) .issueTime(new Date())
.expirationTime(new Date( .expirationTime(new Date(
Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli() Instant.now().plus(EXPIRATION_TIME, ChronoUnit.SECONDS).toEpochMilli()
)) ))
.jwtID(UUID.randomUUID().toString())
.claim("scope", buildScope(user)) .claim("scope", buildScope(user))
.build(); .build();
...@@ -102,12 +119,102 @@ public class AuthenticationService { ...@@ -102,12 +119,102 @@ public class AuthenticationService {
} }
private String buildScope(User user) { private String buildScope(User user) {
StringJoiner stringJoiner = new StringJoiner(""); StringJoiner stringJoiner = new StringJoiner(" ");
//
// if (!CollectionUtils.isEmpty(user.getRoles())) { if (!CollectionUtils.isEmpty(user.getRoles())) {
// user.getRoles().forEach(stringJoiner::add); user.getRoles().forEach(role -> {
// } stringJoiner.add("ROLE_" + role.getName());
if (!CollectionUtils.isEmpty(role.getPermissions())) {
role.getPermissions().forEach(permission ->
stringJoiner.add(permission.getName())
);
}
});
}
return stringJoiner.toString(); return stringJoiner.toString();
} }
public void logout(LogoutRequest request) throws ParseException, JOSEException {
SignedJWT signedJWT = null;
try {
signedJWT = verifyToken(request.getToken(), true);
} catch (AppException a) {
log.info("Token already expired or invalidated");
}
String jit = signedJWT.getJWTClaimsSet().getJWTID();
Date expiryTime = signedJWT.getJWTClaimsSet().getExpirationTime();
InvalidatedToken invalidatedToken = InvalidatedToken.builder()
.id(jit)
.expiryTime(expiryTime)
.build();
saveInvalidatedToken(invalidatedToken);
}
private SignedJWT verifyToken(String token, boolean isRefresh) throws ParseException, JOSEException {
JWSVerifier verifier = new MACVerifier(SIGNER_KEY.getBytes());
SignedJWT signedJWT = SignedJWT.parse(token);
Date expiryTime = !isRefresh
? signedJWT.getJWTClaimsSet().getExpirationTime()
: new Date(signedJWT.getJWTClaimsSet().getIssueTime()
.toInstant().plus(REFRESH_EXPIRATION_TIME, ChronoUnit.SECONDS).toEpochMilli());
var verified = signedJWT.verify(verifier);
if (!(verified && expiryTime.after(new Date()))) {
throw new AppException(ErrorCode.UNAUTHENTICATED);
}
if (existsInvalidatedToken(signedJWT.getJWTClaimsSet().getJWTID())) {
throw new AppException(ErrorCode.UNAUTHENTICATED);
}
return signedJWT;
}
public AuthenticationResponse refreshToken(RefreshTokenRequest request) throws ParseException, JOSEException {
var signedJWT = verifyToken(request.getToken(), true);
String jit = signedJWT.getJWTClaimsSet().getJWTID();
Date expiryTime = signedJWT.getJWTClaimsSet().getExpirationTime();
InvalidatedToken invalidatedToken = InvalidatedToken.builder()
.id(jit)
.expiryTime(expiryTime)
.build();
saveInvalidatedToken(invalidatedToken);
String username = signedJWT.getJWTClaimsSet().getSubject();
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new AppException(ErrorCode.UNAUTHENTICATED));
var token = generateToken(user);
return AuthenticationResponse.builder()
.token(token)
.authenticated(true)
.build();
}
private void saveInvalidatedToken(InvalidatedToken token) {
String key = TOKEN_KEY_PREFIX + token.getId();
long ttlMillis = token.getExpiryTime().getTime() - System.currentTimeMillis();
// Chỉ lưu nếu token chưa hết hạn
if (ttlMillis > 0) {
redisTemplate.opsForValue().set(key, token, ttlMillis, TimeUnit.MILLISECONDS);
}
}
private boolean existsInvalidatedToken(String tokenId) {
return Boolean.TRUE.equals(redisTemplate.hasKey(TOKEN_KEY_PREFIX + tokenId));
}
} }
package com.devteria.identityservice.service;
import com.devteria.identityservice.dto.request.RoleRequest;
import com.devteria.identityservice.dto.response.RoleResponse;
import com.devteria.identityservice.mapper.RoleMapper;
import com.devteria.identityservice.repository.PermissionRepository;
import com.devteria.identityservice.repository.RoleRepository;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class RoleService {
RoleRepository roleRepository;
PermissionRepository permissionRepository;
RoleMapper roleMapper;
public RoleResponse create(RoleRequest request) {
var role = roleMapper.toRole(request);
var permissions = permissionRepository.findAllById(request.getPermissions());
role.setPermissions(new HashSet<>(permissions));
roleRepository.save(role);
return roleMapper.toRoleResponse(role);
}
public List<RoleResponse> getAll() {
var roles = roleRepository.findAll();
return roles.stream()
.map(roleMapper::toRoleResponse)
.toList();
}
public void delete(String id) {
roleRepository.deleteById(id);
}
}
...@@ -7,17 +7,22 @@ import com.devteria.identityservice.entity.User; ...@@ -7,17 +7,22 @@ import com.devteria.identityservice.entity.User;
import com.devteria.identityservice.exception.AppException; import com.devteria.identityservice.exception.AppException;
import com.devteria.identityservice.exception.ErrorCode; import com.devteria.identityservice.exception.ErrorCode;
import com.devteria.identityservice.mapper.UserMapper; import com.devteria.identityservice.mapper.UserMapper;
import com.devteria.identityservice.repository.RoleRepository;
import com.devteria.identityservice.repository.UserRepository; import com.devteria.identityservice.repository.UserRepository;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List; import java.util.List;
@Service @Service
...@@ -28,6 +33,7 @@ public class UserService { ...@@ -28,6 +33,7 @@ public class UserService {
UserRepository userRepository; UserRepository userRepository;
UserMapper userMapper; UserMapper userMapper;
PasswordEncoder passwordEncoder; PasswordEncoder passwordEncoder;
private final RoleRepository roleRepository;
public UserResponse createUser(UserCreationRequest request) { public UserResponse createUser(UserCreationRequest request) {
if (userRepository.existsByUsername(request.getUsername())) if (userRepository.existsByUsername(request.getUsername()))
...@@ -36,22 +42,24 @@ public class UserService { ...@@ -36,22 +42,24 @@ public class UserService {
User user = userMapper.toUser(request); User user = userMapper.toUser(request);
user.setPassword(passwordEncoder.encode(request.getPassword())); user.setPassword(passwordEncoder.encode(request.getPassword()));
// HashSet<String> roles = new HashSet<>();
// roles.add(Role.USER.name());
// user.setRoles(roles);
return userMapper.toUserResponse(userRepository.save(user)); return userMapper.toUserResponse(userRepository.save(user));
} }
@CachePut(value = "user", key = "#userId")
public UserResponse updateUser(String userId, UserUpdateRequest request) { public UserResponse updateUser(String userId, UserUpdateRequest request) {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
userMapper.updateUser(user, request); userMapper.updateUser(user, request);
user.setPassword(passwordEncoder.encode(request.getPassword()));
var roles = roleRepository.findAllById(request.getRoles());
user.setRoles(new HashSet<>(roles));
return userMapper.toUserResponse(userRepository.save(user)); return userMapper.toUserResponse(userRepository.save(user));
} }
@CacheEvict(value = "user", key = "#userId")
public void deleteUser(String userId) { public void deleteUser(String userId) {
userRepository.deleteById(userId); userRepository.deleteById(userId);
} }
...@@ -63,6 +71,7 @@ public class UserService { ...@@ -63,6 +71,7 @@ public class UserService {
.map(userMapper::toUserResponse).toList(); .map(userMapper::toUserResponse).toList();
} }
@Cacheable(value = "user", key = "#id")
@PostAuthorize("returnObject.username == authentication.name or hasRole('ADMIN')") @PostAuthorize("returnObject.username == authentication.name or hasRole('ADMIN')")
public UserResponse getUser(String id) { public UserResponse getUser(String id) {
log.info("Get user with id: {}", id); log.info("Get user with id: {}", id);
...@@ -70,6 +79,7 @@ public class UserService { ...@@ -70,6 +79,7 @@ public class UserService {
.orElseThrow(() -> new RuntimeException("User not found"))); .orElseThrow(() -> new RuntimeException("User not found")));
} }
public UserResponse getMyInfo() { public UserResponse getMyInfo() {
String name = SecurityContextHolder.getContext().getAuthentication().getName(); String name = SecurityContextHolder.getContext().getAuthentication().getName();
User user = userRepository.findByUsername(name).orElseThrow(() -> new AppException(ErrorCode.USER_NOT_EXISTED)); User user = userRepository.findByUsername(name).orElseThrow(() -> new AppException(ErrorCode.USER_NOT_EXISTED));
......
package com.devteria.identityservice.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Target(FIELD)
@Retention(RUNTIME)
@Constraint(validatedBy = {DobValidator.class})
public @interface DobConstraint {
String message() default "Invalid date of birth";
int min();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
package com.devteria.identityservice.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public class DobValidator implements ConstraintValidator<DobConstraint, LocalDate> {
private int min;
@Override
public void initialize(DobConstraint constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
min = constraintAnnotation.min();
}
@Override
public boolean isValid(LocalDate localDate, ConstraintValidatorContext constraintValidatorContext) {
if (localDate == null)
return true;
long years = ChronoUnit.YEARS.between(localDate, LocalDate.now());
return years >= min;
}
}
...@@ -14,4 +14,13 @@ spring: ...@@ -14,4 +14,13 @@ spring:
show-sql: true show-sql: true
jwt: jwt:
signerKey: "1TjXchw5FloESb63Kc+DFhTARvpWL4jUGCwfGWxuG5SIf/1y/LgJxHnMqaF6A/ij" signer-key: "1TjXchw5FloESb63Kc+DFhTARvpWL4jUGCwfGWxuG5SIf/1y/LgJxHnMqaF6A/ij"
\ No newline at end of file expiration-time: 3600
refresh-expiration-time: 86400
redis:
host: 10.3.3.115
port: 6379
password: 123456a@
time-to-live: 60
db: 15
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment