⌨️ 회원가입/로그인 구현
요즘은 Spring Security를 활용하면 쉽게 회원가입과 로그인을 구현할 수 있다.
그러나 그러한 기술의 필요성과 내가 직접 구현 했을 때의 부족함을 비교해 보기 위해 직접 세션 방식으로 구현해보려 한다
1. 회원가입
많은 양의 정보를 받기보다 우선 필요한 최소한의 정보를 받아서 회원 가입을 구현하려 한다.
1.1. UsersDTO
@Getter
@Setter
@ToString
public class UsersDto {
private int id;
@NotBlank(message = "아이디 입력은 필수입니다.")
@Length(max = 30, message = "아이디 길이는 30자 를 넘으면 안됩니다.")
private String name;
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[!@#$%^&*])(?=\\S+$).{8,20}$")
//숫자, 알파벳, 특수문자(!@#$%^&*) 포함 8자 이상 20자 이하
private String password;
@NotBlank(message = "이메일 입력은 필수입니다.")
@Length(max = 30, message = "이메일 길이는 30자 를 넘으면 안됩니다.")
@Email(message = "이메일 형식을 지켜야 합니다.")
private String email;
@NotBlank(message = "주소 입력은 필수입니다.")
@Length(max = 100, message = "주소 길이는 100자 를 넘으면 안됩니다.")
private String addr;
}
- @Getter, @Setter
Lombok에 있는 어노테이션을 활용하여 Getter,Setter를 구현하였다. 당연히 Setter를 그대로 활용하는 것은 위험하다.
이번 구현에서는 팀원들에게 가이드를 주기 위해 최대한 전형적인 방식으로 구현을 했다.
이러한 부분들을 추후에 있을 프로젝트에서는 안정성을 확보하기 위해 불변객체를 활용하여 구현할 예정이다.
1.2. Signup_Form.html
<div class="form">
<form method="POST" action="/signup">
<hidden value="create"></hidden>
<input type="text" name="name" class="input" placeholder="이름" required><br>
<input type="password" name="password" class="input" placeholder="패스워드" required><br>
<input type="email" name="email" class="input" placeholder="Email" required><br>
<section class="address">
<input type="text" name="addr" id="address" class="input" placeholder="주소" required>
<button onclick="searchAddr()" value="주소 찾기" class="btn-address">주소 찾기</button>
</section>
<input type="submit" value="확인" class="btn-signup">
<button class="btn-ref"><a th:href="${referer}" style="text-decoration: none">뒤로가기</a></button>
</form>
</div>
다음은 회원가입의 정보를 받기 위한 폼 html 이다.
스프링의 `Dispatcher Servlet` 이 아주 스마트하게 작동을 하여 폼의 method를 핸들러를 조회하여 매핑한다.
(#스프링 MVC 흐름 참조)
해당 내용은 따로 정리한 페이지를 참고하면 좋을 것 같다. 여기서는 간략하게만 소개하자면
UsersDTO의 변수 네이밍을 form에 전달해 주고픈 데이터에 매치하여 name=" " 속성을 주면 된다.
1.3. Controller
@GetMapping("/signup")
public String index(HttpServletRequest request,
Model model){
String referer = request.getHeader("Referer");
model.addAttribute("referer", referer);
return "signup_Form";
}
@PostMapping("/signup")
public String signUpUser(UsersDto usersDto) {
boolean user = userService.createUser(usersDto);
// 바로 로그인으로 보내주기
return "login_Form";
}
위에서 간략히 소개한 대로 @PostMapping 에는 UsersDto가 들어와 있다. 앞으로 스프링을 통한 웹개발을 목표로 한다면 이 `Dispatcher Servlet` 그리고 DI 컨테이너가 작동하는 방식 2개는 생각보다 더 꼼꼼히 살펴보면 좋다고 생각한다.
다시 본론으로 돌아가자, Thymeleaf 템플릿은 바로 호출이 불가능하기에 GET 메서드를 통해 넘겨주어야 한다.
GET을 통해 위에 Signup_Form.html을 화면에 보여주고, 여기서 POST를 통해 회원가입을 진행한다.
1.4. Service/Mapper
public boolean createUser(UsersDto usersDto){
String password = passwordEncoder.encrypt(usersDto.getEmail(),usersDto.getPassword());
usersDto.setPassword(password);
return userMapper.createUser(usersDto);
}
@Insert("insert into users values (null,#{name},#{password},#{email},#{addr})")
public boolean createUser(UsersDto userDto);
유저 저장이 일어나는 서비스 로직이다. 때론 별도의 과정이 없다면 컨트롤러에서 Mapper를 바로 호출해서 사용하는 경우도 있는데, 선호하는 방식은 아니다. 꼭 나는 Service Layer를 거치는 것을 선호한다.
다양한 이유가 있지만
- 방어 코드 작성
- 중복을 제거할 부분은 제어하자
- 그 외 확장성을 고려하여 컨트롤러에 구현하기보단 서비스 단에서 구현
크게 위 3가지 이유로 서비스 레이어를 꼭 거치는 편이다.
특히 1항의 방어코드가 없이 Mapper를 통해 DB에 바로 접근한다면 위험한 상황이 발생할 수도 있다.
위의 서비스 코드에 보면 passwordEncoder가 있는데 이는 스프링의 encoder가 아니라 내가 직접 구현한 내용이다
2. PasswordEncoder
이것에 대한 내용을 별도의 포스팅을 할까 하다, 관련된 내용이라 같이 묶는 게 좋을 것 같다는 생각에 목차로 나누었다.
기본적인 암호화 방식은 단방향과 양방향이 있다.
내가 생각하는 회원가입에서는 복호화가 불가능한 단방향 방식의 암호화를 해야 한다는 생각이다.
Spring Security의 도움을 받지 않고 구현한 회원가입에서는 별도의 PasswordEncoding 과정이 없어 비밀번호가 평문으로 저장된다.
이를 방지하고자 javax.crypto 패키지를 활용하여 구현하려 한다.
@Component
public class PasswordEncoder {
public String encrypt(String email, String password) {
try {
KeySpec spec = new PBEKeySpec(password.toCharArray(), getSalt(email), 85319, 128);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] hash = factory.generateSecret(spec).getEncoded();
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | UnsupportedEncodingException |
InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
private byte[] getSalt(String email)
throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] keyBytes = email.getBytes("UTF-8");
return digest.digest(keyBytes);
}
}
내가 선택한 알고리즘은 복호화가 불가능한 단방향에서 SHA-512를 선택하였다.
이 포스팅은 암호화 기법에 대한 소개가 아니므로 간단히 설명하고 넘어가겠다.
해시 알고리즘의 단점은 무차별 대입 공격에 대한 약점, 그리고 같은 문자면 같은 digest를 가진다는 것이다.
그것을 해결하기 위해 salt를 통해 차별점을 둔다 문자 그대로 소금을 쳐 간을 맞추는 것처럼 같은 digest가 되지 않도록 처리해주는 과정을 거친다.
나머지는 패키지의 docs를 확인하면 어렵지 않게 구현법을 알 수 있다.
이 서비스에서는 e-mail이 UK이므로 e-mail을 salt를 위한 key로 활용하여 구현했다.
*salt : 해시함수를 돌리기 전 원문에 임의의 문자열을 덧붙이는 방식
3. 마무리
서비스 로직과 흐름이 어려운 내용은 없던 구현이다.
그러나 Spring Security를 활용하지 않고 세션 로그인을 구현해 보세요.
라는 명세를 받았을 때, 밋밋한 평문 구현보다는 조금은 더 고민하고 Java 언어에 대한 이해 (내장 패키지 활용능력)
를 보여줄 수 있다는 생각이다.