728x90
반응형
이번엔 로그인 뿐만 아니라 TOPT, Email을 사용한 복합 인증에 관하여 알아보자.
TOPT는 시간으로 OPT를 인증받는 방식으로, 구글, 마이크로 소프트 등 여러 앱으로 인증이 가능하다.
1. build.gradle 패키지 추가
implementation 'org.jboss.aerogear:aerogear-otp-java:1.0.0'
2. User Entity
- isUsingMfa: 복합인증을 사용하는 유저인지 판단
- mfaTypes: 어떤 복합인증을 사용하는 지 ( [ { mfaType: "EMAIL", registeredAt: "등록 시간" } ] )
- secret: 복합인증에 사용되는 Base32 인코딩 값
@Column(name = "is_using_mfa")
private boolean isUsingMfa;
@Column
@Convert(converter = MfaTypesConverter.class)
private List<MfaTypesDto> mfaTypes;
@Column
private String secret;
User가 만들어질 때 secret을 지정해준다.
public long create(UserCreateDto createDto) {
repository.findByUsernameAndDeletedAtIsNull(createDto.getUsername())
.ifPresent(u -> {
log.error("[This username already exists] Username: {}", createDto.getUsername());
throw new IllegalArgumentException("This username already exists.");
});
if (createDto.getUsername().isBlank() | createDto.getPassword().isBlank()) {
log.error("[ID or PW was entered incorrectly] Username: {}", createDto.getUsername());
throw new IllegalArgumentException("ID or PW was entered incorrectly.");
}
String random = Base32.random();
while (repository.findBySecretAndDeletedAtIsNull(random).isPresent()) {
random = Base32.random();
}
createDto.setSecret(random);
createDto.setPassword(passwordEncoder.encode(createDto.getPassword()));
createDto.setGroups(setUserGroup(createDto.getGroupIds()).stream().toList());
return repository.save(new User(createDto)).getId();
}
3. MfaTypesDto
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MfaTypesDto {
private MfaType mfaType;
private LocalDateTime registeredAt;
}
4. MfaType Enum
public enum MfaType {
OTP,
EMAIL
}
5. MfaTypesConverter
@Converter
public class MfaTypesConverter implements AttributeConverter<List<MfaTypesDto>, String> {
private final ObjectMapper mapper;
public MfaTypesConverter(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public String convertToDatabaseColumn(List<MfaTypesDto> attribute) {
try {
return mapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@Override
public List<MfaTypesDto> convertToEntityAttribute(String dbData) {
try {
return mapper.readValue(dbData, new TypeReference<List<MfaTypesDto>>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
여기까지 엔티티에 대한 구성을 완료 하였고, 이제 부터 필요한 것은
- 복합 인증 사용자가 로그인을 할 경우 쿠키 및 세션을 제공하지 않는다.
- 복합 인증 사용자는 인증을 거쳐야 쿠키 및 세션이 등록된다.
일단 필요한 Dto들을 먼저 보자면,
6. MfaEmailSendRequestDto
이메일 전송 시 Request
@Data
@AllArgsConstructor
@NoArgsConstructor
@EventLoggingDto
public class MfaEmailSendRequestDto {
@NotNull
private String email;
}
7. MfaEmailVerifyRequestDto
이메일 검증 시 Request
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaEmailVerifyRequestDto {
@NotNull
private String email;
@NotNull
private String code;
}
8. MfaOtpVerifyDto
Otp 검증 시 Request
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaOtpVerifyDto {
@NotNull
private String email;
private String code;
}
9. ResponseLoginDto
로그인 성공 시 Response
@Data
@AllArgsConstructor
public class ResponseLoginDto {
private boolean isUsingMfa;
private List<MfaType> mfaTypes;
private MfaType currentMfaType;
private Long id;
private String name;
private String firstName;
private String lastName;
private List<String> groupNames;
private List<String> roles;
private Long exp;
public ResponseLoginDto(
boolean isUsingMfa,
List<MfaType> mfaTypes,
Long id,
String name,
String firstName,
String lastName,
List<String> groupNames,
List<String> roles,
Long exp
) {
MfaType currentMfaType = null;
if (mfaTypes.contains(MfaType.OTP)) {
currentMfaType = MfaType.OTP;
} else if (mfaTypes.contains(MfaType.E_MAIL)) {
currentMfaType = MfaType.E_MAIL;
}
this.isUsingMfa = isUsingMfa;
this.mfaTypes = mfaTypes;
this.currentMfaType = currentMfaType;
this.id = id;
this.name = name;
this.firstName = firstName;
this.lastName = lastName;
this.groupNames = groupNames;
this.roles = roles;
this.exp = exp;
}
}
10. UserService
- registerMfaTypes: 유저의 MfaTypes 정보를 새로 등록한다.
- deleteMfaTypes: 유저의 MfaTypes 중 원하는 것을 제거한다.
- registerAuthenticator: OTP 인증 중 secret 값을 만들고 유저에 등록하며 클라이언트로 보내주는 로직
- verifyAuthenticatorCode: TOPT 검증하는 로직
- sendAuthenticationCodeByEmail: 이메일 전송 로직 ( Spring Boot | Custom 메일 보내기 ( with JavaMailSender ) :: 티포의개발일지 (tistory.com) ) 참고
- certEmail: 이메일 검증 로직
- emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다.
- VERIFY_TIMEOUT: 서버에서의 이메일 검증 만료 시간
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {
public static final int VERIFY_TIMEOUT = 35;
private final UserRepository repository;
private final EmailService emailService;
private final UserGroupRepository userGroupRepository;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
...
public void registerMfaTypes(MfaRegisterDto mfaRegisterDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(mfaRegisterDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (mfaTypeList.contains(mfaRegisterDto.getMfaType())) {
throw new IllegalArgumentException("이미 등록된 인증 입니다.");
}
mfaTypes.add(new MfaTypesDto(mfaRegisterDto.getMfaType(), LocalDateTime.now()));
user.updateIsUsingMfa(true);
user.updateMfaTypes(mfaTypes);
}
public void deleteMfaTypes(MfaDeleteDto deleteDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(deleteDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (!mfaTypeList.contains(deleteDto.getMfaType())) {
throw new IllegalArgumentException("등록되지 않은 인증 입니다.");
}
mfaTypes.removeIf(dto -> dto.getMfaType() == deleteDto.getMfaType());
if (mfaTypes.size() == 0) {
user.updateIsUsingMfa(false);
}
user.updateMfaTypes(mfaTypes);
}
public String registerAuthenticator(MfaOtpRegisterDto dto) {
return repository
.findByUsernameAndDeletedAtIsNull(dto.getId())
.orElseThrow()
.getSecret();
}
public void verifyAuthenticatorCode(MfaOtpVerifyDto dto) {
String name = dto.getEmail();
User user = repository.findByUsernameAndDeletedAtIsNull(name).orElseThrow();
Totp totp = new Totp(user.getSecret());
if (!isValidLong(dto.getCode()) || !totp.verify(dto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
private boolean isValidLong(String code) {
try {
Long.parseLong(code);
} catch (NumberFormatException e) {
return false;
}
return true;
}
public void sendAuthenticationCodeByEmail(String userId) {
repository.findByUsernameAndDeletedAtIsNull(userId).orElseThrow();
Random num = new Random();
StringBuilder randomNumber = new StringBuilder();
for (int i = 0; i < 6; i++) {
randomNumber.append(num.nextInt(10));
}
EmailSendDto emailSendDto = new EmailSendDto(
AUTH,
"이메일 서버",
포트,
"이메일 주소",
userId,
"",
"",
true,
TLS_V_1_2,
"인증번호",
"인증번호는 " + randomNumber + " 입니다.",
null
);
emailService.sendMail(emailSendDto);
this.emailCertNumberMap.put(
userId,
new MfaEmailVerifyMapDto(
randomNumber.toString(),
LocalDateTime.now()
)
);
}
public void certEmail(MfaEmailVerifyRequestDto mfaEmailVerifyRequestDto) {
String name = mfaEmailVerifyRequestDto.getEmail();
MfaEmailVerifyMapDto mapDto = this.emailCertNumberMap.get(name);
Duration between = Duration.between(LocalDateTime.now(), mapDto.getCreatedAt());
if (Math.abs(between.getSeconds()) > VERIFY_TIMEOUT) {
throw new AuthorizationException("Code Expired");
}
if (!Objects.equals(mapDto.getCode(), mfaEmailVerifyRequestDto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
}
11. AuthService
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
public ResponseLoginDto login(RequestLoginDto loginDto) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(loginDto.getId()).orElseThrow();
if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
log.error("[Password does not match] Username: {}", user.getUsername());
throw new TokenProblemException("Password does not match");
}
if (user.getIsBlocked()) {
log.error("[User login is blocked] Username: {}", user.getUsername());
throw new AccessDeniedException("Blocked user.");
}
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginDto.getId(), loginDto.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles,
null
);
}
@Retry(value = 2)
public ResponseLoginDto refreshToken(String username) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(username).orElseThrow();
Set<String> roles = new HashSet<>();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
roles.addAll(List.of(group.getRolesList()));
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles.stream().toList(),
null);
}
public long getExpiredTime(Cookie jwtCookie) {
return Timestamp.valueOf(LocalDateTime.now().plusSeconds(jwtCookie.getMaxAge()))
.getTime();
}
}
최종적으로 API를 만들기 위한 단계이다. API는 로그인 시 필요한 AuthController, 사용자 별 복합 인증 관리에 필요한 UserController 두 곳에서 사용할 것이다.
12. AuthController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private static final int CLIENT_VERIFY_TIMEOUT = 30;
private final AuthService service;
private final UserService userService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
@EventLogging(code = SecurityEventCode.USER_LOGIN)
public CommonResponse<ResponseLoginDto> login(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody RequestLoginDto loginDto
) {
ResponseLoginDto responseLoginDto = service.login(loginDto);
if (!responseLoginDto.isUsingMfa()) {
long exp = addJwtCookie(response, loginDto.getId());
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
return new CommonResponse<>(responseLoginDto);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Long> verifyAuthenticatorCode(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody MfaOtpVerifyDto dto
) {
verifySessionMfaType(session, OTP);
userService.verifyAuthenticatorCode(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/mfa/email/send")
public CommonResponse<Integer> sendAuthenticationCodeByEmail(
@RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
userService.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(CLIENT_VERIFY_TIMEOUT);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Long> certEmail(
HttpServletResponse response,
HttpSession session,
@RequestBody MfaEmailVerifyRequestDto dto
) {
verifySessionMfaType(session, EMAIL);
userService.certEmail(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/logout")
@EventLogging(code = SecurityEventCode.USER_LOGOUT)
public CommonResponse<Boolean> logout(
HttpServletResponse response,
HttpSession session
) {
ResponseCookie jwtCookie = jwtUtil.getCleanJwtCookie();
response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString());
return new CommonResponse<>(true);
}
@PostMapping("/refresh")
public CommonResponse<ResponseLoginDto> refresh(
HttpServletResponse response,
HttpServletRequest request
) {
String token = this.jwtUtil.getJwtFromCookies(request);
if ((token == null) || (token.length() < 1)) {
log.error("[Invalid token] token: {}", token);
throw new TokenProblemException("Invalid token");
}
if (!jwtUtil.validateJwtToken(token)) {
log.error("[The token has expired] token: {}", token);
throw new TokenProblemException("The token has expired.");
}
String username = jwtUtil.getUsernameFromJwtToken(token);
Cookie jwtCookie = jwtUtil.generateJwtCookie(username);
response.addCookie(jwtCookie);
var exp = service.getExpiredTime(jwtCookie);
ResponseLoginDto responseLoginDto = service.refreshToken(username);
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
private void verifySessionMfaType(HttpSession session, MfaType mfaType) {
MfaType isUsingMfa = sessionUtil.getIsUsingMfa(session);
if (isUsingMfa != null && isUsingMfa != mfaType) {
throw new IllegalArgumentException("이미 다른 복합 인증이 사용중입니다.");
}
sessionUtil.setIsUsingMfa(session, mfaType);
}
private long addJwtCookie(HttpServletResponse response, String name) {
Cookie jwtCookie = jwtUtil.generateJwtCookie(name);
response.addCookie(jwtCookie);
return service.getExpiredTime(jwtCookie);
}
}
13. UserController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
@PreAuthorize("hasRole('ROLE_USER_READ') || hasRole('ROLE_USER_ADMIN')")
public class UserController {
private final UserService service;
@GetMapping
public CommonResponse<List<ResponseUserDto>> findAll() {
return new CommonResponse<>(service.findAll());
}
@GetMapping("/{id}")
public CommonResponse<ResponseUserDto> findByIndexId(
@PathVariable(value = "id") long userId) {
return new CommonResponse<>(service.findByIndexId(userId));
}
@GetMapping("/name/{username}")
public CommonResponse<Boolean> findByUsername(
@PathVariable(value = "username") String username) {
return new CommonResponse<>(service.isUserEmptyByUsername(username));
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PostMapping
@EventLogging(code = SecurityEventCode.USER_USER_CREATE)
public CommonResponse<Long> create(
@RequestBody @Valid UserCreateDto createDto) {
Long id = service.create(createDto);
createDto.setPassword("");
return new CommonResponse<>(id);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE)
public CommonResponse<Boolean> updateUser(
@RequestBody @Valid UserUpdateDto updateDto) {
service.updateUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/password")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE)
public CommonResponse<Boolean> updatePasswordByUser(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/me")
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE_OWN_USER)
public CommonResponse<Boolean> updateOwnUser(
@RequestBody @Valid OwnUserUpdateDto updateDto) {
service.updateOwnUser(updateDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/register")
public CommonResponse<Boolean> registerMfaTypes(
@Valid @RequestBody MfaRegisterDto registerDto
) {
service.registerMfaTypes(registerDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Boolean> verifyAuthenticatorCode(
@Valid @RequestBody MfaOtpVerifyDto dto
) {
service.verifyAuthenticatorCode(dto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/delete")
public CommonResponse<Boolean> deleteMfaTypes(
@Valid @RequestBody MfaDeleteDto deleteDto
) {
service.deleteMfaTypes(deleteDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/register")
public CommonResponse<String> registerAuthenticator(
@Valid @RequestBody MfaOtpRegisterDto otpRegisterDto
) {
return new CommonResponse<>(service.registerAuthenticator(otpRegisterDto));
}
@PostMapping("/mfa/email/send")
public CommonResponse<Boolean> sendAuthenticationCodeByEmail(
@Valid @RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
service.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(true);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Boolean> certEmail(
@Valid @RequestBody MfaEmailVerifyRequestDto dto
) {
service.certEmail(dto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/admin")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE_BY_ADMIN)
public CommonResponse<Boolean> updatePasswordByAdmin(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByAdmin(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@DeleteMapping
@EventLogging(code = SecurityEventCode.USER_USER_DELETE)
public CommonResponse<Boolean> deleteUsers(
@Valid @RequestBody CommonIdListDto listDto) {
service.deleteUsers(listDto);
return new CommonResponse<>(true);
}
}
클라이언트 QR code 예시
`otpauth://totp/typosblog:${username}?secret=${secretQrCode}&issuer=typosblog`
728x90
반응형