일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 쿼리 최적화
- 객체지향
- 캡슐화
- JPA
- 클린 코드
- 리팩토링
- 책임
- spring boot
- 재사용성
- cache
- Lombok
- 추상화
- SRP
- 객체지향의 사실과 오해
- 협력
- 인터프리터
- 스프링부트
- REST API
- 스프링
- 클린코드
- JIT
- 자바
- clean code
- Java
- 객체
- 캐시
- Refactor
- 캐싱
- string
- 도메인 모델
- Today
- Total
GO SIWOO!
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (4) - 회원기능 구현을 위한 스프링 Security와 JWT발급 본문
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (4) - 회원기능 구현을 위한 스프링 Security와 JWT발급
gosiwoo 2023. 5. 19. 02:46📌 기존의 회원기능
기존 프로젝트의 회원 기능은 kakao API를 통해서 진행했다. 그러나, 하드코딩 된 API 호출을 보고 적용하지 않고 프론트 팀에서 전달받은 인증 코드를 바탕으로 회원 정보를 HashMap 형태로 (설정파일도 사용하지 않았다) 이를 Controller 단에서 Session으로 저장해 두는 방식을 사용하였는데...
@Service
public class KakaoAPI {
public String getAccessToken(String authorize_code) {
String access_Token = "";
String refresh_Token = "";
String reqURL = "https://kauth.kakao.com/oauth/token";
try {
URL url = new URL(reqURL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
// POST 요청을 위해 기본값이 false인 setDoOutput을 true로
conn.setRequestMethod("POST");
conn.setDoOutput(true);
// POST 요청에 필요로 요구하는 파라미터 스트림을 통해 전송
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
StringBuilder sb = new StringBuilder();
sb.append("grant_type=authorization_code");
sb.append("&client_id={client_id}");
sb.append("&client_secret={client_secret}");
sb.append("&redirect_uri=https://kotudy.netlify.app/kakaoAuth");
sb.append("&code=" + authorize_code);
bw.write(sb.toString());
bw.flush();
int responseCode = conn.getResponseCode();
System.out.println("responseCodelogin : " + responseCode);
// 요청을 통해 얻은 JSON타입의 Response 메세지 읽어오기
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line = "";
String result = "";
...
위의 코드를 보면 어떻게 굴러가는지 파악하기도 힘들뿐더러 설정파일을 통해 손쉽게 설정이 가능한 부분까지 하드코딩 되어있다. 또한 clientSecret이 코드로 노출되어 있었다. 또한 Spring Security 없이 OAuth 2.0 인증을 진행을 했는데, 따라서 인증 필요 여부를 프론트 팀에게 전부 맡겼던 기억이 있다. 그 당시의 프론트 팀이 힘들어했던 이유를 드디어 알 것 같다.
📌 리팩토링
우선 위에서 보다시피 로그인 로직을 전부 다시 구성해야 한다.
그렇다면 어떻게 리팩토링을 진행할 것인가? 우선 기존의 kakao API를 통한 회원 로그인을 우선 제거하고 Spring Security + JWT를 통해 인증 인가 처리를 하기로 했다. 추 후 리팩토링 프로젝트가 궤도에 올랐을 때 소셜 로그인을 추가할 예정이다.
기존 프로젝트는 session 기반 인증을 사용했지만 이번 리팩토링 과정에서는 token 기반의 인증을 사용하기로 했다. 인증 정보 저장의 부담을 클라이언트에 부담할 수 있고 비밀 키 관리와 token의 payload 내부에 민감한 정보만 넣지 않으면 토큰을 탈취당하더라도 요청 body에 같이 입력되는 로그인 정보의 인증을 통해 보안성을 챙길 수 있다고 생각했다.
추가로 후에 추가될 소셜 로그인 OAuth 2.0 또한 토큰방식을 통해 진행되기에 확장성을 챙길 수 있다고 생각해 JWT를 통해 인증 처리를 하기로 결정했다.
Member.java
회원 Entity이다. 기존에 구상했던 프로젝트의 Member Entity와는 조금 다른데, name 필드는 username 필드와 중복된다고 생각해 삭제, image 필드는 kakao API를 통해 프로필 사진을 가져오고자 하였지만 kakao API 사용을 뒤로 미룬 이상 삭제가 불가피했다, 마지막으로 memberType 필드의 이름은 MemberRole로 변경, MyWord 엔티티와 다대다 연결을 위한 memberMyWords 필드가 추가되었다.
BaseEntity는 생성, 수정 일자를 관리하기 위한 엔티티이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
private MemberRole role; // ROLE_ADMIN, ROLE_USER, ROLE_GUEST
@OneToMany(mappedBy = "member")
private List<MemberMyWord> memberMyWords = new ArrayList<>();
public void addMemberMyWord(MemberMyWord memberMyWord) {
this.getMemberMyWords().add(memberMyWord);
if (memberMyWord.getMember() != this) {
memberMyWord.setMember(this);
}
}
public Member(String username, String password) {
this.username = username;
this.password = password;
this.role = MemberRole.ROLE_USER;
}
}
AuthenticationConfig.java
회원가입 기능과 로그인 기능은 인증이 필요 없도록 설정했고, 나만의 단어장 기능을 사용하기 위해서는 인증을 받아야 하게 설정했다, 또한 아래 등장할 JwtFilter를 UsernamePasswordAuthenticationFilter가 적용되기 전에 작동하도록 등록해 주었다.
Spring Security FilterChain 설정은 WebSecurityConfigurerAdapter 등 deprecated 된 것들이 너무 많아 자료를 찾는 것에 꽤나 애를 먹었다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig {
private final MemberService memberService;
private final JwtProvider jwtProvider;
@Value("${jwt.token.secretKey}")
private String secretKey;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers("/api/v1/member/join", "/api/v1/member/login").permitAll()
.antMatchers("/api/v1/myword").authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //jwt에 사용
.and()
// JwtFfilter를 인증 전에 수행
.addFilterBefore(new JwtFilter(memberService, secretKey, jwtProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
EncoderConfig.java
회원의 비밀번호를 인코딩하기 위한 메서드를 Bean으로 등록해 주었다. Spring Security 설정파일에 보통 두지만 순환참조가 일어날 가능성이 있어 클래스를 따로 만들어 주었다.
@Configuration
public class EncoderConfig {
@Bean
public BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
}
JwtFilter.java
헤더를 통해 전달받은 JWT 통해 회원 인증을 진행할 JwtFilter이다. 토큰이 헤더에 없거나 Token이 만료되었을 경우에 인증이 실패하도록 하였다.
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final MemberService memberService;
private final String secretKey;
private final JwtProvider jwtProvider;
public JwtFilter(MemberService memberService, String secretKey, JwtProvider jwtProvider) {
this.memberService = memberService;
this.secretKey = secretKey;
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("authorization : {}", authorization);
if (isAuthorizationNotExist(request, response, filterChain, authorization)) return;
// Token 꺼내기
String token = authorization.split(" ")[1];
// Token 만료
if (isTokenExpired(request, response, filterChain, token)) return;
String username = jwtProvider.getUsername(token);
log.info("username : {}", username);
// 권한 부여
authorize(request, response, filterChain, username);
}
private boolean isAuthorizationNotExist(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain, String authorization) throws IOException, ServletException {
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.info("authorization 이 없거나 잘못 보냈습니다.");
filterChain.doFilter(request, response);
return true;
}
return false;
}
private boolean isTokenExpired(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain,
String token) throws IOException, ServletException {
if (jwtProvider.isExpired(token)) {
log.error("Token이 만료 되었습니다.");
filterChain.doFilter(request, response);
return true;
}
return false;
}
private void authorize(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain,
String username) throws IOException, ServletException {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, null, List.of(new SimpleGrantedAuthority("USER")));
// Detail 넣기
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
JwtProvider.java
JWT를 생성, 만료, 값 추출을 하는 클래스이다. 추 후 refresh토큰을 재발급하는 기능을 추가할 예정이다.
처음 로직을 짤 때 Claims 클래스 없이 setSubject(payload) 메서드를 통해 작성했지만 JWT에서 값을 추출할 때 불편하여 Claims 클래스를 사용하였다.
@Component
public class JwtProvider {
private final SecretKey secretKey;
private final long validityInMilliseconds;
public JwtProvider(@Value("${jwt.token.secretKey}") final String secretKey,
@Value("${jwt.token.expire-length}") final long validityInMilliseconds) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
public String createToken(String username) {
Claims claims = Jwts.claims();
claims.put("username", username);
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public boolean isExpired(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration()
.before(new Date());
}
public String getUsername(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody()
.get("username", String.class);
}
}
MemberService.java
앞서 구현했던 JWT를 사용한 회원가입, 로그인 기능을 수행하는 Service 클래스이다.
@Service
@RequiredArgsConstructor
@Slf4j
public class MemberService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder encoder;
private final JwtProvider jwtProvider;
public JoinResponse join(String username, String password) {
// username 중복 체크
memberRepository.findByUsername(username)
.ifPresent(user -> {
throw new AppException(ErrorCode.USERNAME_DUPLICATED, username + "회원님은 이미 있습니다.");
});
Member createdMember = new Member(username, encoder.encode(password));
memberRepository.save(createdMember);
return new JoinResponse("회원 가입이 완료되었습니다.", createdMember.getId(), createdMember.getUsername());
}
public LoginResponse login(String username, String password) {
// username 없음
Member selectedMember = memberRepository.findByUsername(username)
.orElseThrow(() -> new AppException(ErrorCode.BODY_BAD_REQUEST, username + "회원이(가) 없습니다."));
// password 틀림
if (!encoder.matches(password, selectedMember.getPassword())) {
throw new AppException(ErrorCode.INVALID_PASSWORD, "패스워드를 잘못 입력했습니다.");
}
// 앞에서 Exception 안났으면 토큰 발행
return new LoginResponse("로그인이 완료되었습니다.", jwtProvider.createToken(selectedMember.getUsername()));
}
}
📌 다음은?
아직 Refresh Token 추가, Refresh Token을 저장할 Redis 인메모리 스토리지 추가, JWT 재발행, 소셜 로그인 추가 등등 회원 기능에서 추가적으로 구현해야 할 기능도 많고 작성한 코드도 보기 좋게 고쳐야 하지만 구현해야 할 기능이 많아 다른 기능을 구현한 후 하기로 했다.
다음은 로그인 API를 호출하며 발생하는 예외를 JSON 형태로 응답하게 커스텀할 예정이다.
'Develop > 팀 프로젝트, 나홀로 리팩토링' 카테고리의 다른 글
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (6) - Redis를 통한 Open API 결과 캐싱(Caching) (0) | 2023.09.06 |
---|---|
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (5) - 응답 form & Global Exception (0) | 2023.08.17 |
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (3) - distinct, rand, limit, 프로젝션 쿼리 작성과 에러 (0) | 2023.05.12 |
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (2) - 기존의 프로젝트 파악과 엔티티, 기능수정 (0) | 2023.05.10 |
[리팩토링 일지] 팀 프로젝트, 나홀로 리팩토링 (1) - 리팩토링 시작 (0) | 2023.05.09 |