Fridge 프로젝트의 Fridge, Food Entity 클래스의 @CreatedDate, @LastModifedDate 컬럼이 중복되어 나타나므로 상위클래스로 옮긴다. 상위 클래스는 BaseEntity 클래스로 선언하고 두개의 멤버 변수를 선언한다. Fridge, Food 클래스는 BaseEntity 를 상속받는다.
Food 클래스는 다음과 같이 클래스 애너테이션이 선언된다.
lombok @Getter, @Setter, @ToString 애너테이션을 선언하고 LocalDateTime createDate, LocalDateTime lastModifiedDate 필드는 제거한다.
Fridge 클래스도 동일하게 수정한다.
UI 서버에서 로그인을 한 뒤에, 비밀번호 변경과 회원탈퇴 기능은 제공되지만, 비밀번호를 잊어버렸을 경우 로그인을 할 수 없는 문제가 있다. 따라서, 비밀번호 찾기 기능을 제공한다.
비밀번호 찾기 기능은 가입시 입력한 이메일을 입력하면 해당 이메일로 인증코드가 발송되고, 해당 메일에서 인증코드 링크를 클릭하면 비밀번호를 변경할 수 있는 페이지를 제공한다.
인증코드는 Member 서비스에서 관리하는게 적합한데, 개별 회원정보가 Member 서비스에서 관리되기 때문이다. Member 프로젝트의 스키마를 수정한다.
member 테이블에 추가되는 컬럼은 password_token, password_token_expire_time, password_token_try 세 개의 컬럼이다.
password_token 은 문자열 20자리로 인증토큰이 저장된다.
password_token_expire_time 은 Timestamp 형태로, 토큰 인증 만료시간을 나타낸다.
password_token_try 는 정수형으로, 토큰 인증 시도횟수를 저장한다. 기본값은 0이다.
Member 엔티티에 필드를 추가한다.
passwordToken, passwordTokenExpireTime, passwordTokenTry 세개의 멤버 변수가 추가되었다.
이제 UI 서버에서 로그인 폼페이지에서 비밀번호를 찾을 수 있는 링크를 제공하고, Security 인증이 없어도 접근가능하도록 WebSecurityConfig 클래스를 수정한다.
(비밀번호 찾기 페이지 URL은 /forgotPassword 이다.)
antMatchers메서드에 가변인자로 /sign**/**, /webjars/**, /error**, /forgotPassword**/** ANT 매쳐문자열들이 permitAll 로 선언되어, 인증없이 접근가능하다.
인증 절차를 위해서 필요한 폼페이지는 각각
이렇게 세장의 html이 준비되어야 한다.
인증메일을 입력하는 폼페이지는 단순히 email 값만 입력받는다.
비밀번호 변경 폼페이지에서는 인증메일에서 링크로 넘어온 token 문자열값과 변경할 비밀번호를 입력값으로 전달받는다.
View 페이지와 Controller 사이의 파라미터를 전달해줄 Command 클래스 MemberRequest 클래스는 다음과 같이 수정한다.
passwordToken, passwordTokenExpireTime, passwordTokenTry 세 필드는 각각 인증토큰, 인증만료시간, 인증횟수를 나타내며 token 필드는 인증메일에서 전달되는 token값을 전달하기 위한 필드이다.
MemberController에서 가입시 입력한 메일을 입력하는 폼페이지는 다음과 같이 추가한다.
forgotPassword 컨트롤러 메서드는 단순하게 model 에 빈 memberRequest커맨드 객체를 전달해서 View에서 form 필드를 채워주는 역할을 한다.
폼페이지에서 이메일을 입력하고 submit을 하면 forgotPasswordProcessing 메서드가 실행된다.
전달되는 @ModelAttribute MemberRequest 객체에는 username 파라미터값이 전달된다.
FeignClient의 loadByUsername 메서드를 실행해서 Member API 서버로 부터 전달하는 username에 해당하는 데이터가 존재하는지 확인한다. 데이터가 존재하지 않을 경우 MemberNotFoundException 이 발생하고 catch 구문으로 이동해서 Validator에 reject해주고 폼페이지로 포워딩한다.
MemberService 클래스의 sendVerificationMailForForgotPassword 메서드가 호출되며 파라미터로 Member API 서버에서 리턴된 회원정보를 넘겨준다.
그리고 View에 sendAuthMailDone model값을 전달해서 인증메일이 성공적으로 발송되었다는 플래그를 전달한다.
만약, sendVerificationMailForForgotPassword 메서드에서 인증메일을 발송하는 부분이 오류가 발생하면 Validator에 rejectValue를 선언하고 폼페이지로 포워딩해서 사용자에게 알린다.
MemberService를 다음과 같이 작성한다.
MemberService 클래스의 멤버 변수로는 메일발신자 이메일주소를 나타내는 sender, 인증토큰 만료시간을 설정할때 설정할 tokenExpireTime, 인증토큰 시도횟수를 나타내는 tokenTryLimit, 인증토큰의 길이를 나타내는 MAX_TOKEN_SIZE 전역변수가 있다.
전역변수를 제외하고는 전부 Property를 통해 설정을 지원하도록 @Value 애너테이션을 선언해둬서, 차후에 설정값을 변경할 경우를 대비한다.
sendVerificationMailForForgotPassword 메서드는 사용자가 폼페이지에서 입력한 username 파라미터를 Member API 에 호출해서 Member 정보를 리턴한 것을 메서드 파라미터로 전달받는다.
메서드 내부 로직은 다음과 같은 절차를 거친다.
1. generatePasswordToken 메서드는 Apache Commons Lang API를 사용해서 랜덤한 알파벳과 숫자의 조합으로 MAX_TOKEN_SIZE 길이만큼을 리턴한다.
3. setPasswordTokenExpireTime 메서드는 java.time.LocalDateTime 클래스를 이용해서 호출된 시간으로부터 tokenExpireTime 분(minute) 만큼 더한 값을 리턴한다.
6. encodeToken 메서드는 username과 passwordToken 파라미터를 전달받아서 "username:token" 형태로 문자열을 하나로 만들고 Base64 인코딩하여 리턴한다. Base64 인코딩을 통해서 URL도 간략하게 표현하고 URL을 표현하는경우 인코딩문제에서도 안전하다.
NotificationService 클래스는 Java Mail API를 추상화한 Spring Mail API를 활용한다. 메이븐 pom.xml 에 spring-boot-starter-mail 을 추가한다.
NotificationService 클래스는 다음과 같이 작성한다.
org.springframework.mail.javamail.JavaMailSender 클래스를 멤버 변수로 설정하는데 의존성 주입을 위해 상수로 선언한다. Thymeleaf API를 활용해서 html파일을 템플릿으로 사용하기 위해 org.thymeleaf.TemplateEngine 클래스도 멤버 변수로 선언한다.
EMAIL_FORGOT_PASSWORD_VERIFICATION_TEMPLATE_NAME 전역변수는 html 템플릿파일의 경로와 파일명을 나타낸다.
sendNotificationForgotPasswordVerification 메서드에는 @Async 애너테이션이 선언되어 있다.
스프링에서 @Async 애너테이션을 선언하면 비동기 메서드로 동작한다.
비동기 메서드로 동작한다는 말은 별도의 쓰레드에 메서드를 동작한다는 뜻이다.
메일 발송과 같이 로컬 서버가 아닌 외부 서버를 호출해야 하는 작업같은 경우는 어떤 상황이 벌어질지 예상하기 힘들다. 네트워크가 복잡해지거나, 메일서버가 바쁘거나, 서버가 다운되어있거나 하는 경우에 로컬서버에까지 영향을 미치게 된다.
메일 발송의 경우 메일서버가 바빠지면 발송까지 시간이 걸리게 되며, 그 시간동안 사용자는 브라우져가 반응하기를 기다리게된다. 이를 방지하기 위해 비동기로 별도의 스레드에 작업을 전달하면 사용자는 즉각 메일이 발송되었다는 응답을 받게된다.
하지만, 별도의 스레드로 메일이 발송되기 때문에 얼마의 시간이 걸릴지는 알 수 없다.
@Async 애너테이션을 사용하려면 Application.class 파일에 @EnableAsync 애너테이션을 선언한다.
별도의 설정없이 @EnableAsync 애너테이션만 사용하면 org.springframework.core.task.SimpleAsyncTaskExecutor 클래스가 비동기로 호출되는 쓰레드를 관리한다. SimpleAsyncTaskExecutor 클래스는 스레드를 재사용하지 않고 계속 만들어내므로 적합하지 않다.
스레드를 생성하는것은 서버의 메모리를 그만큼 사용하는것이기 때문에, 일정한 개수의 스레드를 한정해서 만들어내는것이 좋다.
또한, 스레드를 생성하고 소멸하는 과정에 드는 연산시간 오버헤드를 줄이기위해 스레드풀을 통한 스레드 재사용이 더 효율적이다.
Application.class 파일에 ThreadPool Bean 메서드를 선언한다.
asyncExecutor 빈 메서드는 java.util.concurrent.Executor 인터페이스타입을 리턴한다.
메서드 내부를 보면 Spring 의 ThreadPoolTaskExecutor 를 생성하고, CPU 코어 수만큼 pool size와 최대 pool size를 설정한다.
스레드 대기 큐의 용량은 500개로 설정한다. 스레드이름 접두사는 Notification- 이다.
계속해서 NotificationService 클래스의 sendNotificationForgotPasswordVerification 메서드를 보면 파라미터로 from, to, context 를 전달받는다.
from은 발신자 이메일주소, to는 수신자 이메일 주소를 나타낸다. context는 Thymeleaf API클래스로 템플릿 html 에 전달될 변수들을 담고있다.
Spring Mail API를 사용해서 MimeMessageHelper 클래스를 선언하고 해당 인스턴스에 발신자, 수신자, 제목, 내용을 담는다.
내용을 담는 부분은 templateEngine의 process 메서드를 호출해서 첫번째 파라미터는 html 파일경로, 두번째는 Context 클래스를 전달한다. 리턴되는 값은 String 형태로 변환된 html 문자열이다.
MimeMessageHelper 인스턴스에 setText 메서드로 메일 내용을 설정하는데, 두번째 파라미터로 전달되는 boolean값은 true일 경우 html 형태의 내용임을 알려주는 flag이다.
javaMailSender 객체의 send메서드 파라미터로 MimeMessageHelper 인스턴스를 전달하면 메일이 발송이 시작된다.
메일에 담겨진 인증링크는 /forgotPasswordVerify/{token} 와 같은 형태이다. 이 앵커태그를 클릭해서 /forgotPasswordVerify URL을 GET 방식으로 호출하면 MemberController 의 forgotPasswordVerify 메서드가 호출된다.
forgotPasswordVerify 메서드는 token URI 변수를 String token으로 전달받고, MemberRequest 객체는 폼 페이지의 Command 객체로 사용하기 위해 전달된다.
MemberService 의 verifyTokenForgotPassword 메서드에 token 파라미터를 전달하여 리턴되는 boolean 값이 false일 경우 forgotPasswordVerifyFailed html을 포워딩하여 인증실패 View 페이지를 응답한다.
boolean 값이 true일 경우 View 페이지에 memberRequest를 전달하고 forgotPasswordVerify 뷰페이지를 응답한다. forgotPasswordVerify 뷰페이지는 변경할 비밀번호를 입력받을 폼페이지이다.
MemberService 클래스의 verifyTokenForgotPassword 메서드는 다음과 같다.
verifyTokenForgotPassword 메서드는 다음 절차대로 인증토큰을 검증한다.
1. decodeToken 메서드에 token 문자열을 전달해서 Base64로 decode하면 원래의 username:token 값을 문자열 배열로 리턴한다.
6. isValidToken 메서드는 passwordToken 값이 일치하고, 인증만료시간 이전인 경우 true를 리턴한다.
토큰이 성공적으로 인증되면 비밀번호 변경 폼페이지(forgotPasswordVerify.html)이 뷰페이지로 전달된다. 해당 뷰페이지의 폼은 변경할 password 필드와 token 문자열이 hidden 필드로 들어있다. 변경할 비밀번호를 입력한 후 submit하면 MemberController의 forgotPasswordVerifyProcess 메서드가 호출된다.
forgotPasswordVerifyProcess 메서드는 전달받은 커맨드 객체 MemberRequest를 MemberService.passwordChangeWithToken 메서드의 파라미터로 전달한다.
리턴값이 false일 경우 인증에 실패했음을 의미하기 때문에 폼페이지로 포워딩한다.
인증에 성공한 경우 비밀번호가 변경되었음을 알리는 FlashAttribute 값을 설정하고 로그인페이지(/signin)로 리다이렉트한다.
passwordChangeWithToken 메서드는 verifyTokenForgotPassword 메서드와 거의 흡사하다.
decodeToken 메서드로 토큰을 username과 passwordToken 으로 디코딩한다.
username으로 회원정보를 Member API에서 가져온뒤 isValidToken메서드로 토큰이 일치하는지, 만료시간 전인지를 확인한다.
토큰이 유효하면 토큰 시도횟수를 tokenTryLimit 값으로 설정하고, 비밀번호를 파라미터로 전달받은 비밀번호로 변경한다.
비밀번호는 passwordEncoder로 해싱한다.
변경된 Member정보를 Member 서버에 반영한 뒤 true를 리턴한다.
application.yml 파일에 환경설정 변수를 선언한다.
spring.mail.host는 메일서버 host값이고, spring.mail.port는 메일서버의 port이다.
메일서버는 localhost:2525 에서 동작함을 알 수 있다.
noti.sender, noti.token-expire-time, noti.token-try-limit 값은 순서대로
메일 발신자 이메일주소, 토큰만료시간, 토큰인증횟수를 의미한다.
token-expire-time 값이 15이므로 메일 발송시간으로부터 15분 후에는 인증코드가 만료된다.
token-try-limit 값이 5이므로 인증최대 가능횟수는 5번이다.
실제로 메일을 발송하기 위해서는 Gmail SMTP를 사용하는 방법도 있지만, 로컬에서는 fakeSMTP Server를 사용하면 편리하다.
FakeSMTP 에서 jar파일을 다운로드 받고 실행한뒤 Server start 버튼을 클릭하면 메일을 수신한다.
다음과 같이 메일이 수신되면 fakeSMTP에서 확인이 가능하다.
이전글: 냉장고 가계부 프로젝트 37
다음글: 냉장고 가계부 프로젝트 39
@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public class BaseEntity { private @CreatedDate LocalDateTime createdDate; private @LastModifiedDate LocalDateTime lastModifiedDate; }
Food 클래스는 다음과 같이 클래스 애너테이션이 선언된다.
@NoArgsConstructor @Getter @Setter @ToString @Entity public class Food extends BaseEntity { ... }
lombok @Getter, @Setter, @ToString 애너테이션을 선언하고 LocalDateTime createDate, LocalDateTime lastModifiedDate 필드는 제거한다.
Fridge 클래스도 동일하게 수정한다.
UI 서버에서 로그인을 한 뒤에, 비밀번호 변경과 회원탈퇴 기능은 제공되지만, 비밀번호를 잊어버렸을 경우 로그인을 할 수 없는 문제가 있다. 따라서, 비밀번호 찾기 기능을 제공한다.
비밀번호 찾기 기능은 가입시 입력한 이메일을 입력하면 해당 이메일로 인증코드가 발송되고, 해당 메일에서 인증코드 링크를 클릭하면 비밀번호를 변경할 수 있는 페이지를 제공한다.
인증코드는 Member 서비스에서 관리하는게 적합한데, 개별 회원정보가 Member 서비스에서 관리되기 때문이다. Member 프로젝트의 스키마를 수정한다.
CREATE TABLE member ( id BIGINT GENERATED BY DEFAULT AS IDENTITY(START WITH 1, INCREMENT BY 1) PRIMARY KEY, username VARCHAR(256) NOT NULL, password VARCHAR(128) NOT NULL, account_non_expired BOOLEAN NOT NULL DEFAULT TRUE, account_non_locked BOOLEAN NOT NULL DEFAULT TRUE, credentials_non_expired BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE, password_token VARCHAR(20), password_token_expire_time TIMESTAMP, password_token_try INTEGER DEFAULT 0 );
member 테이블에 추가되는 컬럼은 password_token, password_token_expire_time, password_token_try 세 개의 컬럼이다.
password_token 은 문자열 20자리로 인증토큰이 저장된다.
password_token_expire_time 은 Timestamp 형태로, 토큰 인증 만료시간을 나타낸다.
password_token_try 는 정수형으로, 토큰 인증 시도횟수를 저장한다. 기본값은 0이다.
Member 엔티티에 필드를 추가한다.
@NoArgsConstructor @Data @Entity public class Member { @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; @NaturalId @Column(unique=true) private String username; private String password; private boolean accountNonExpired = true; private boolean accountNonLocked = true; private boolean credentialsNonExpired = true; private boolean enabled = true; @ElementCollection @Column(name="authority") private Set<String> authorities = new HashSet<>(); private String passwordToken; private LocalDateTime passwordTokenExpireTime; private int passwordTokenTry; public Member(String username, String password) { this(username, password, null); } @Builder public Member(String username, String password, Set<String> authorities) { this.username = username; this.password = password; if(authorities != null) { this.authorities.addAll(authorities); } } }
passwordToken, passwordTokenExpireTime, passwordTokenTry 세개의 멤버 변수가 추가되었다.
이제 UI 서버에서 로그인 폼페이지에서 비밀번호를 찾을 수 있는 링크를 제공하고, Security 인증이 없어도 접근가능하도록 WebSecurityConfig 클래스를 수정한다.
(비밀번호 찾기 페이지 URL은 /forgotPassword 이다.)
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final MemberUserDetailsService userDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/sign**/**", "/webjars/**", "/error**", "/forgotPassword**/**").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/signin") .and() .logout() .logoutUrl("/signout") .logoutSuccessUrl("/") .and() .rememberMe() .and() .csrf() .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .and() .httpBasic(); } ... }
antMatchers메서드에 가변인자로 /sign**/**, /webjars/**, /error**, /forgotPassword**/** ANT 매쳐문자열들이 permitAll 로 선언되어, 인증없이 접근가능하다.
인증 절차를 위해서 필요한 폼페이지는 각각
- 인증메일을 입력할 폼페이지(/forgotPassword)
- 인증메일에서 인증코드 링크를 클릭시 호출될 비밀번호 변경 폼페이지(/forgotPasswordVerify/{token})
- 인증에 실패할 경우 보여줄 에러페이지(/forgotPasswordVerifyFailed)
이렇게 세장의 html이 준비되어야 한다.
인증메일을 입력하는 폼페이지는 단순히 email 값만 입력받는다.
비밀번호 변경 폼페이지에서는 인증메일에서 링크로 넘어온 token 문자열값과 변경할 비밀번호를 입력값으로 전달받는다.
View 페이지와 Controller 사이의 파라미터를 전달해줄 Command 클래스 MemberRequest 클래스는 다음과 같이 수정한다.
@NoArgsConstructor @Data public class MemberRequest { private Long id; @Size(min=6, max=128) private String username; @Size(min=8, max=72) private String password; @JsonIgnore private String currentPassword; private Set<String> authorities; private boolean accountNonExpired; private boolean accountNonLocked; private boolean credentialsNonExpired; private boolean enabled; private String passwordToken; private LocalDateTime passwordTokenExpireTime; private int passwordTokenTry; private String token; ... }
passwordToken, passwordTokenExpireTime, passwordTokenTry 세 필드는 각각 인증토큰, 인증만료시간, 인증횟수를 나타내며 token 필드는 인증메일에서 전달되는 token값을 전달하기 위한 필드이다.
MemberController에서 가입시 입력한 메일을 입력하는 폼페이지는 다음과 같이 추가한다.
@Controller @RequiredArgsConstructor @Slf4j public class MemberController { private final PasswordEncoder passwordEncoder; private final MemberClient client; private final MemberService service; ... @GetMapping("/forgotPassword") public String forgotPassword(MemberRequest memberRequest, Model model) { model.addAttribute("memberRequest", memberRequest); return "members/forgotPassword"; } @PostMapping("/forgotPassword") public String forgotPasswordProcessing(@ModelAttribute MemberRequest memberRequest, Errors errors, Model model) { String username = memberRequest.getUsername(); try { MemberRequest savedMember = client.loadByUsername(username); service.sendVerificationMailForForgotPassword(savedMember); model.addAttribute("sendAuthMailDone", true); } catch(MemberNotFoundException ex) { errors.rejectValue("username", "field.not_exists.member.username"); return "members/forgotPassword"; } catch (UnsupportedEncodingException | MessagingException e) { errors.rejectValue("username", "field.noti_failed.member.username"); return "members/forgotPassword"; } return "members/forgotPassword"; } ... }
forgotPassword 컨트롤러 메서드는 단순하게 model 에 빈 memberRequest커맨드 객체를 전달해서 View에서 form 필드를 채워주는 역할을 한다.
폼페이지에서 이메일을 입력하고 submit을 하면 forgotPasswordProcessing 메서드가 실행된다.
전달되는 @ModelAttribute MemberRequest 객체에는 username 파라미터값이 전달된다.
FeignClient의 loadByUsername 메서드를 실행해서 Member API 서버로 부터 전달하는 username에 해당하는 데이터가 존재하는지 확인한다. 데이터가 존재하지 않을 경우 MemberNotFoundException 이 발생하고 catch 구문으로 이동해서 Validator에 reject해주고 폼페이지로 포워딩한다.
MemberService 클래스의 sendVerificationMailForForgotPassword 메서드가 호출되며 파라미터로 Member API 서버에서 리턴된 회원정보를 넘겨준다.
그리고 View에 sendAuthMailDone model값을 전달해서 인증메일이 성공적으로 발송되었다는 플래그를 전달한다.
만약, sendVerificationMailForForgotPassword 메서드에서 인증메일을 발송하는 부분이 오류가 발생하면 Validator에 rejectValue를 선언하고 폼페이지로 포워딩해서 사용자에게 알린다.
MemberService를 다음과 같이 작성한다.
@Service @RequiredArgsConstructor @Slf4j public class MemberService { private final MemberClient client; private final NotificationService notification; private final PasswordEncoder passwordEncoder; @Value("${noti.sender}") private String sender; @Value("${noti.token-expire-time}") private long tokenExpireTime; @Value("${noti.token-try-limit}") private int tokenTryLimit; public static final int MAX_TOKEN_SIZE = 20; public void sendVerificationMailForForgotPassword(MemberRequest memberRequest) throws MessagingException, UnsupportedEncodingException { String passwordToken = generatePasswordToken(); memberRequest.setPasswordToken(passwordToken); memberRequest.setPasswordTokenExpireTime(setPasswordTokenExpireTime()); memberRequest.setPasswordTokenTry(0); client.changeMember(memberRequest.getId(), memberRequest); String token = encodeToken(memberRequest.getUsername(), passwordToken); final Context context = new Context(); context.setVariable("token", token); notification.sendNotificationForgotPasswordVerification(sender, memberRequest.getUsername(), context); } private String generatePasswordToken() { return RandomStringUtils.randomAlphanumeric(MAX_TOKEN_SIZE); } private LocalDateTime setPasswordTokenExpireTime() { return LocalDateTime.now().plusMinutes(tokenExpireTime); } private String encodeToken(String username, String passwordToken) throws UnsupportedEncodingException { String source = username + ":" + passwordToken; byte[] src = source.getBytes("UTF-8"); String token = Base64.getUrlEncoder().encodeToString(src); log.info("source: " + source); return token; } ... }
MemberService 클래스의 멤버 변수로는 메일발신자 이메일주소를 나타내는 sender, 인증토큰 만료시간을 설정할때 설정할 tokenExpireTime, 인증토큰 시도횟수를 나타내는 tokenTryLimit, 인증토큰의 길이를 나타내는 MAX_TOKEN_SIZE 전역변수가 있다.
전역변수를 제외하고는 전부 Property를 통해 설정을 지원하도록 @Value 애너테이션을 선언해둬서, 차후에 설정값을 변경할 경우를 대비한다.
sendVerificationMailForForgotPassword 메서드는 사용자가 폼페이지에서 입력한 username 파라미터를 Member API 에 호출해서 Member 정보를 리턴한 것을 메서드 파라미터로 전달받는다.
메서드 내부 로직은 다음과 같은 절차를 거친다.
- generatePasswordToken메서드를 호출해서 인증토큰을 생성한다.
- memberRequest에 생성한 인증토큰을 설정한다.
- setPasswordTokenExpireTime메서드를 호출해서 인증만료시간을 생성하고 memberRequest에 설정한다.
- 인증시도횟수는 최초 0회로 설정한다.
- Member FeignClient를 사용해서 member 객체를 반영한다.
- encodeToken메서드를 호출해서 인증메일 속에 인증링크 토큰을 생성한다.
- Thymeleaf API인 Context 클래스를 생성하고 인증메일 html 템플릿에서 사용할 변수를 선언한다.
- NotificationService의 sendNotificationForgotPasswordVerification 메서드를 호출하여 메일을 발송한다.
1. generatePasswordToken 메서드는 Apache Commons Lang API를 사용해서 랜덤한 알파벳과 숫자의 조합으로 MAX_TOKEN_SIZE 길이만큼을 리턴한다.
3. setPasswordTokenExpireTime 메서드는 java.time.LocalDateTime 클래스를 이용해서 호출된 시간으로부터 tokenExpireTime 분(minute) 만큼 더한 값을 리턴한다.
6. encodeToken 메서드는 username과 passwordToken 파라미터를 전달받아서 "username:token" 형태로 문자열을 하나로 만들고 Base64 인코딩하여 리턴한다. Base64 인코딩을 통해서 URL도 간략하게 표현하고 URL을 표현하는경우 인코딩문제에서도 안전하다.
NotificationService 클래스는 Java Mail API를 추상화한 Spring Mail API를 활용한다. 메이븐 pom.xml 에 spring-boot-starter-mail 을 추가한다.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency>
NotificationService 클래스는 다음과 같이 작성한다.
@Component @Slf4j
@RequiredArgsConstructor public class NotificationService { private final JavaMailSender javaMailSender; private final TemplateEngine templateEngine; private static final String EMAIL_FORGOT_PASSWORD_VERIFICATION_TEMPLATE_NAME = "mail/forgotPassword"; @Async public void sendNotificationForgotPasswordVerification(String from, String to, Context context) throws MessagingException { log.info("Sending Email..."); final MimeMessage mimeMessage = javaMailSender.createMimeMessage(); final MimeMessageHelper message = new MimeMessageHelper(mimeMessage, "UTF-8"); message.setTo(to); message.setFrom(from); message.setSubject("Fridge Account Verification"); templateEngine.getTemplateResolvers().forEach(resolver -> { log.info("name: " + resolver.getName() + ", order: " + resolver.getOrder()); }); final String htmlContent = templateEngine.process(EMAIL_FORGOT_PASSWORD_VERIFICATION_TEMPLATE_NAME, context); message.setText(htmlContent, true); javaMailSender.send(mimeMessage); log.info("Email sent."); } }
org.springframework.mail.javamail.JavaMailSender 클래스를 멤버 변수로 설정하는데 의존성 주입을 위해 상수로 선언한다. Thymeleaf API를 활용해서 html파일을 템플릿으로 사용하기 위해 org.thymeleaf.TemplateEngine 클래스도 멤버 변수로 선언한다.
EMAIL_FORGOT_PASSWORD_VERIFICATION_TEMPLATE_NAME 전역변수는 html 템플릿파일의 경로와 파일명을 나타낸다.
sendNotificationForgotPasswordVerification 메서드에는 @Async 애너테이션이 선언되어 있다.
스프링에서 @Async 애너테이션을 선언하면 비동기 메서드로 동작한다.
비동기 메서드로 동작한다는 말은 별도의 쓰레드에 메서드를 동작한다는 뜻이다.
메일 발송과 같이 로컬 서버가 아닌 외부 서버를 호출해야 하는 작업같은 경우는 어떤 상황이 벌어질지 예상하기 힘들다. 네트워크가 복잡해지거나, 메일서버가 바쁘거나, 서버가 다운되어있거나 하는 경우에 로컬서버에까지 영향을 미치게 된다.
메일 발송의 경우 메일서버가 바빠지면 발송까지 시간이 걸리게 되며, 그 시간동안 사용자는 브라우져가 반응하기를 기다리게된다. 이를 방지하기 위해 비동기로 별도의 스레드에 작업을 전달하면 사용자는 즉각 메일이 발송되었다는 응답을 받게된다.
하지만, 별도의 스레드로 메일이 발송되기 때문에 얼마의 시간이 걸릴지는 알 수 없다.
@Async 애너테이션을 사용하려면 Application.class 파일에 @EnableAsync 애너테이션을 선언한다.
@EnableHypermediaSupport(type=HypermediaType.HAL) @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients @EnableAsync public class Application { @Bean public LayoutDialect layoutDialect() { return new LayoutDialect(); } public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
별도의 설정없이 @EnableAsync 애너테이션만 사용하면 org.springframework.core.task.SimpleAsyncTaskExecutor 클래스가 비동기로 호출되는 쓰레드를 관리한다. SimpleAsyncTaskExecutor 클래스는 스레드를 재사용하지 않고 계속 만들어내므로 적합하지 않다.
스레드를 생성하는것은 서버의 메모리를 그만큼 사용하는것이기 때문에, 일정한 개수의 스레드를 한정해서 만들어내는것이 좋다.
또한, 스레드를 생성하고 소멸하는 과정에 드는 연산시간 오버헤드를 줄이기위해 스레드풀을 통한 스레드 재사용이 더 효율적이다.
Application.class 파일에 ThreadPool Bean 메서드를 선언한다.
@EnableHypermediaSupport(type=HypermediaType.HAL) @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients @EnableAsync public class Application { @Bean public LayoutDialect layoutDialect() { return new LayoutDialect(); } @Bean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); int processors = Runtime.getRuntime().availableProcessors(); executor.setCorePoolSize(processors); executor.setMaxPoolSize(processors); executor.setQueueCapacity(500); executor.setThreadNamePrefix("Notification-"); executor.initialize(); return executor; } public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
asyncExecutor 빈 메서드는 java.util.concurrent.Executor 인터페이스타입을 리턴한다.
메서드 내부를 보면 Spring 의 ThreadPoolTaskExecutor 를 생성하고, CPU 코어 수만큼 pool size와 최대 pool size를 설정한다.
스레드 대기 큐의 용량은 500개로 설정한다. 스레드이름 접두사는 Notification- 이다.
계속해서 NotificationService 클래스의 sendNotificationForgotPasswordVerification 메서드를 보면 파라미터로 from, to, context 를 전달받는다.
from은 발신자 이메일주소, to는 수신자 이메일 주소를 나타낸다. context는 Thymeleaf API클래스로 템플릿 html 에 전달될 변수들을 담고있다.
Spring Mail API를 사용해서 MimeMessageHelper 클래스를 선언하고 해당 인스턴스에 발신자, 수신자, 제목, 내용을 담는다.
내용을 담는 부분은 templateEngine의 process 메서드를 호출해서 첫번째 파라미터는 html 파일경로, 두번째는 Context 클래스를 전달한다. 리턴되는 값은 String 형태로 변환된 html 문자열이다.
MimeMessageHelper 인스턴스에 setText 메서드로 메일 내용을 설정하는데, 두번째 파라미터로 전달되는 boolean값은 true일 경우 html 형태의 내용임을 알려주는 flag이다.
javaMailSender 객체의 send메서드 파라미터로 MimeMessageHelper 인스턴스를 전달하면 메일이 발송이 시작된다.
메일에 담겨진 인증링크는 /forgotPasswordVerify/{token} 와 같은 형태이다. 이 앵커태그를 클릭해서 /forgotPasswordVerify URL을 GET 방식으로 호출하면 MemberController 의 forgotPasswordVerify 메서드가 호출된다.
public class MemberController { ... @GetMapping("/forgotPasswordVerify/{token}") public String forgotPasswordVerify(@PathVariable("token") String token, MemberRequest memberRequest, Model model) throws UnsupportedEncodingException { if(!service.verifyTokenForgotPassword(token)) { return "members/forgotPasswordVerifyFailed"; } model.addAttribute("memberRequest", memberRequest); return "members/forgotPasswordVerify"; } }
forgotPasswordVerify 메서드는 token URI 변수를 String token으로 전달받고, MemberRequest 객체는 폼 페이지의 Command 객체로 사용하기 위해 전달된다.
MemberService 의 verifyTokenForgotPassword 메서드에 token 파라미터를 전달하여 리턴되는 boolean 값이 false일 경우 forgotPasswordVerifyFailed html을 포워딩하여 인증실패 View 페이지를 응답한다.
boolean 값이 true일 경우 View 페이지에 memberRequest를 전달하고 forgotPasswordVerify 뷰페이지를 응답한다. forgotPasswordVerify 뷰페이지는 변경할 비밀번호를 입력받을 폼페이지이다.
MemberService 클래스의 verifyTokenForgotPassword 메서드는 다음과 같다.
public class MemberService { ... public boolean verifyTokenForgotPassword(String token) throws UnsupportedEncodingException { String[] sources = decodeToken(token); if(sources.length == 2) { String username = sources[0]; String passwordToken = sources[1]; MemberRequest memberRequest = client.loadByUsername(username); int tryCount = memberRequest.getPasswordTokenTry(); if(tryCount < tokenTryLimit) { memberRequest.setPasswordTokenTry(tryCount + 1); client.changeMember(memberRequest.getId(), memberRequest); return isValidToken(memberRequest, passwordToken); } } return false; } private String[] decodeToken(String token) throws UnsupportedEncodingException { byte[] decoded = Base64.getUrlDecoder().decode(token); String source = new String(decoded, "UTF-8"); String[] sources = source.split(":"); if(sources.length == 2) { log.info("username: " + sources[0] + ", passwordToken: " + sources[1]); return sources; } return null; } private boolean isValidToken(MemberRequest memberRequest, String passwordToken) { return memberRequest.getPasswordToken().equals(passwordToken) && memberRequest.getPasswordTokenExpireTime().isAfter(LocalDateTime.now()); } ... }
verifyTokenForgotPassword 메서드는 다음 절차대로 인증토큰을 검증한다.
- decodeToken 메서드로 토큰을 username과 passwordToken으로 decode한다.
- Member API 서버에 username을 전달해서 회원정보를 가져온다.
- 인증횟수가 tokenTryLimit 보다 작을 경우
- 인증횟수를 한번 증가시킨 후
- Member API 서버에 반영한다.
- isValidToken메서드로 토큰을 검증한 결과 boolean값을 리턴한다.
1. decodeToken 메서드에 token 문자열을 전달해서 Base64로 decode하면 원래의 username:token 값을 문자열 배열로 리턴한다.
6. isValidToken 메서드는 passwordToken 값이 일치하고, 인증만료시간 이전인 경우 true를 리턴한다.
토큰이 성공적으로 인증되면 비밀번호 변경 폼페이지(forgotPasswordVerify.html)이 뷰페이지로 전달된다. 해당 뷰페이지의 폼은 변경할 password 필드와 token 문자열이 hidden 필드로 들어있다. 변경할 비밀번호를 입력한 후 submit하면 MemberController의 forgotPasswordVerifyProcess 메서드가 호출된다.
public class MemberController { ... @PostMapping("/forgotPasswordVerify") public String forgotPasswordVerifyProcess(@ModelAttribute @Valid MemberRequest memberRequest, Errors errors, RedirectAttributes redirectAttributes) throws UnsupportedEncodingException { if(errors.hasErrors()) { return "members/forgotPasswordVerify"; } if(!service.passwordChangeWithToken(memberRequest)) { errors.rejectValue("token", "field.verify_failed.member.token"); return "members/forgotPasswordVerify"; } redirectAttributes.addFlashAttribute("changePasswordDone", true); return "redirect:/signin"; } }
forgotPasswordVerifyProcess 메서드는 전달받은 커맨드 객체 MemberRequest를 MemberService.passwordChangeWithToken 메서드의 파라미터로 전달한다.
리턴값이 false일 경우 인증에 실패했음을 의미하기 때문에 폼페이지로 포워딩한다.
인증에 성공한 경우 비밀번호가 변경되었음을 알리는 FlashAttribute 값을 설정하고 로그인페이지(/signin)로 리다이렉트한다.
public class MemberService { ... public boolean passwordChangeWithToken(MemberRequest memberRequest) throws UnsupportedEncodingException { String[] sources = decodeToken(memberRequest.getToken()); if(sources.length == 2) { String username = sources[0]; String passwordToken = sources[1]; MemberRequest updatedMember = client.loadByUsername(username); if(isValidToken(updatedMember, passwordToken)) { updatedMember.setPasswordTokenTry(tokenTryLimit); updatedMember.setPassword(passwordEncoder.encode(memberRequest.getPassword())); client.changeMember(updatedMember.getId(), updatedMember); return true; } } return false; } }
passwordChangeWithToken 메서드는 verifyTokenForgotPassword 메서드와 거의 흡사하다.
decodeToken 메서드로 토큰을 username과 passwordToken 으로 디코딩한다.
username으로 회원정보를 Member API에서 가져온뒤 isValidToken메서드로 토큰이 일치하는지, 만료시간 전인지를 확인한다.
토큰이 유효하면 토큰 시도횟수를 tokenTryLimit 값으로 설정하고, 비밀번호를 파라미터로 전달받은 비밀번호로 변경한다.
비밀번호는 passwordEncoder로 해싱한다.
변경된 Member정보를 Member 서버에 반영한 뒤 true를 리턴한다.
application.yml 파일에 환경설정 변수를 선언한다.
spring: application: name: fridge-ui mail: host: localhost port: 2525 server: port: 8083 logging: level: org.springframework.security: DEBUG com.poseidon.fridge.service.FridgeClient: DEBUG com.poseidon.fridge.service.MemberClient: DEBUG feign: client: config: default: loggerLevel: FULL fridge-member: errorDecoder: com.poseidon.config.MemberFeignErrorDecoder fridge-service: errorDecoder: com.poseidon.config.FridgeFeignErrorDecoder noti: sender: no-reply@fridge.com token-expire-time: 15 token-try-limit: 5
spring.mail.host는 메일서버 host값이고, spring.mail.port는 메일서버의 port이다.
메일서버는 localhost:2525 에서 동작함을 알 수 있다.
noti.sender, noti.token-expire-time, noti.token-try-limit 값은 순서대로
메일 발신자 이메일주소, 토큰만료시간, 토큰인증횟수를 의미한다.
token-expire-time 값이 15이므로 메일 발송시간으로부터 15분 후에는 인증코드가 만료된다.
token-try-limit 값이 5이므로 인증최대 가능횟수는 5번이다.
실제로 메일을 발송하기 위해서는 Gmail SMTP를 사용하는 방법도 있지만, 로컬에서는 fakeSMTP Server를 사용하면 편리하다.
FakeSMTP 에서 jar파일을 다운로드 받고 실행한뒤 Server start 버튼을 클릭하면 메일을 수신한다.
다음과 같이 메일이 수신되면 fakeSMTP에서 확인이 가능하다.
이전글: 냉장고 가계부 프로젝트 37
다음글: 냉장고 가계부 프로젝트 39