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

  1. registerMfaTypes: 유저의 MfaTypes 정보를 새로 등록한다.
  2. deleteMfaTypes: 유저의 MfaTypes 중 원하는 것을 제거한다.
  3. registerAuthenticator: OTP 인증 중 secret 값을 만들고 유저에 등록하며 클라이언트로 보내주는 로직
  4. verifyAuthenticatorCode: TOPT 검증하는 로직
  5. sendAuthenticationCodeByEmail: 이메일 전송 로직 ( Spring Boot | Custom 메일 보내기 ( with JavaMailSender ) :: 티포의개발일지 (tistory.com) ) 참고
  6. certEmail: 이메일 검증 로직
  7. emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다. 
  8. 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
반응형

+ Recent posts