OncePerRequestFilter : RequestDispatcher 클래스에 의해 다른 서블릿으로 dispatch 되더라도, Filter가 실행되지 않는다.
추후 Oauth 구현을 위해 OncePerRequestFilter로 설정하였다.
doFilterInternal
Header에서 Authorization을 활용해 accessToken을 검증한다
엑세스 토큰이 만료되었을 경우 Exception을 캐치하여 response에 에러 메세지를 반환해준다
엑세스 토큰이 정상적일 경우, SecurityContextHolder에 Authenticaton을 가져와 설정해준다.
엑세스 토큰이 없을 경우 설정 없이 필터 체인을 거치게 된다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers("/login/member").permitAll() // 해당 API 모든 요청 허가
.antMatchers("/login/token/reissue").permitAll() // 해당 API 모든 요청 허가
.antMatchers("/login/test").authenticated()
// .anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class);
return http.build();
// 그 외 나머지 요청은 인증 필요
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
passwordEncoder : 단방향 암호화 문제를 해결하기 위한 팩토리패턴을 활용해 빈으로 등록하였다. 추후 암호화 알고리즘 변경시에도 적용하기 좋다
Config를 통해 필터체인을 등록한다. 이때 내가 만든 JwtAuthenticationFilter 가 시큐리티보다 먼저 적용될 수 있도록 해야한다.
Cors 정책으로 인해 HTTP OPTIONS는 모두 허용 해주었다.
/login/member, /login/token/reissue는 권한이 없는 사용자의 요청이므로, 해당 요청에 대해서는 모든 요청을 허락 해주어야 한다.
/login/test 요청은 authenticated를 통해 인가 확인하고 실행하도록 한다
reissueAccessToken : “/login/token/reissue” - 쿠키에 담긴 refreshToken을 활용해서 토큰을 재발급 한다.
LoginMemberService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class LoginMemberService {
private final LoginMemberRepository loginMemberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public TokenInfo login(LoginMemberRequestDto loginMemberRequestDto) {
log.info("login start");
String memberEmail = loginMemberRequestDto.getMemberEmail();
String password = loginMemberRequestDto.getPassword();
// 1. MemberEmail, Password로 Member 조회
Member member = loginMemberRepository.findByEmail(memberEmail)
.orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다."));
if (!member.getPassword().equals(password)) {
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
}
// 2. 토큰 생성
TokenInfo tokenInfo = createNewToken(memberEmail, password);
// 3. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, member);
log.info("end generateToken");
return tokenInfo;
}
@Transactional
public Object reissue(String refreshToken) {
log.info("reissue start");
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// Refresh Token Rotation 기법 사용, 유효한 Refresh Token인지 확인
// 유효하지 않을 경우 Exception 발생
// 유효할 경우 Refresh Token 가져와서 사용
RefreshToken refreshTokenEntity = refreshTokenRotation(refreshToken);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
return new UsedTokenError("토큰 탈취 에러", HttpStatus.BAD_REQUEST.toString(),"Refresh Token이 이미 사용되었습니다.");
}
refreshTokenEntity.updateStatus(RefreshTokenStatus.INVALID);
// 3. 토큰 생성
TokenInfo tokenInfo = createNewToken(refreshTokenEntity.getMember().getEmail(), refreshTokenEntity.getMember().getPassword());
// 4. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, refreshTokenEntity.getMember());
log.info("tokenInfo: {}", tokenInfo);
return tokenInfo;
}
/**
* Refresh Token Rotation 기법 적용
*/
private RefreshToken refreshTokenRotation(String refreshToken) {
Optional<RefreshToken> token = refreshTokenRepository.findById(refreshToken);
if (token.isEmpty()) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
RefreshToken refreshTokenEntity = token.get();
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
// 탈취된 토큰이 활용된 것이므로, 관련 멤버의 모든 Refresh Token을 무효화한다.
// refresh 토큰은 한번만 사용할 수 있도록 한다.
refreshTokenRepository.updateAllByMember(refreshTokenEntity.getMember(), RefreshTokenStatus.INVALID);
}
return refreshTokenEntity;
}
/**
* 새로운 토큰 생성
*/
private TokenInfo createNewToken(String memberEmail, String password) {
log.info("createNewToken start");
// 1. Refresh Token으로 MemberEmail 조회
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberEmail, password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
log.info("authentication: {}", authentication);
// 2. 새로운 토큰 생성 - refresh token도 새로 생성해서 나가게 된다.
return jwtTokenProvider.generateToken(authentication);
}
/**
* Refresh Token 저장
*/
private void saveRefreshToken(TokenInfo tokenInfo, Member refreshTokenEntity) {
log.info("saveRefreshToken start");
RefreshToken newRefreshToken = new RefreshToken(tokenInfo.getRefreshToken(), RefreshTokenStatus.VALID,
refreshTokenEntity);
refreshTokenRepository.save(newRefreshToken);
}
}
login
아이디 비밀번호를 활용해 토큰을 발급한다.
발급된 토큰의 Refresh Token을 DB에 저장한다.
reissue
유효한 RefreshToken일 경우 재발급 해주는 로직이다.
사용된 RefreshToken은 INVALID로 변경시켜 주며, 새로운 토큰을 Refresh와 Access를 함께 보내준다. (RTR 적용을 위해)
저장소에 새로 발급된 RefreshToken을 저장한다
refreshTokenRoation
DB에서 refreshToken이 존재하는지 확인한다.
존재한다면 refreshToken이 유효한지 status를 확인한다.
유효하지 않은 refreshToken이 요청올 경우 관련 멤버의 모든 RefreshToken을 INVALID로 변경한다.
트랜잭션 안에서 작동하기 때문에, 에러를 반환하지 않고 정상로직으로 작동하며 refreshTokenEntity 값이 INVALID 이기 때문에, 해당 값을 활용하여 Error객체를 반환하도록 설정한다.
createNewToken
이메일과 패스워드를 활용해 새로운 토큰을 발급해준다.
saveRefreshToken
Repository에 토큰을 저장한다.
💡
추후 RTR 방식을 변경할 것이다. 현재는 refreshToken이 유효한 경우와 유효하지 않은 경우가 모두 DB에 저장되어 있다. 하지만 이 중에서 유효한 refreshToken을 Redis에 저장시키면 유효한 사용자의 재발급 요청은 더 빠르게 처리될 수 있을 것이다. 사용된 토큰은 redis에서 삭제하면 된다. 유효하지 않은 refreshToken으로 요청이 온다면 RDBMS에 저장된 데이터베이스에서 해당 토큰을 찾고, 만약에 해당 토큰이 저장되어 있다면 이미 사용된 토큰이므로 레디스에서 해당 유저와 관련된 모든 토큰을 만료시킬 수 있을 것이다. 이때 레디스에 저장하는 key는 token이 아니라 userEmail을 기반으로 해야할 것이다. 토큰 만료 시나리오에서 userEmail을 사용해 찾아야하기 때문이다.
영속성 객체는 ID로 관리되는데, email을 통해 불러오려고 하니, 영속성 객체가 아닌 DB를 참고하여 가지고 왔다
이후 CustomUserDetailsService 를 수정하여 쿼리를 한번으로 줄일 수 있었다
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final LoginMemberRepository loginMemberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
return loginMemberRepository.findById(Long.valueOf(userId)) // 해당 부분이 findByEmail로 되어 있었음
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(userId + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getId().toString()) // username에 email을 넣어주던 것을 Id로 수정하였다.
.password(passwordEncoder.encode(member.getPassword()))
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
Spring Data JPA 에서도 join fetch를 사용하여 member를 한번에 불러오도록 하였다.
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
@Query("select r from RefreshToken r join fetch r.member where r.refreshToken = :refreshToken")
Optional<RefreshToken> findFetchByRefreshToken(@Param("refreshToken") String refreshToken);
}
이후 재발급 서비스에서도 Email이 아닌 Id를 사용하도록 하였으며, JwtProvider에서 sub에도 email이 아니라 id를 넣도록 하였다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class LoginMemberService {
private final LoginMemberRepository loginMemberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public Object reissue(String refreshToken) {
log.info("reissue start");
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// Refresh Token Rotation 기법 사용, 유효한 Refresh Token인지 확인
// 유효하지 않을 경우 Exception 발생
// 유효할 경우 Refresh Token 가져와서 사용
RefreshToken refreshTokenEntity = refreshTokenRotation(refreshToken);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
return new UsedTokenError("토큰 탈취 에러", HttpStatus.BAD_REQUEST.toString(), "Refresh Token이 이미 사용되었습니다.");
}
refreshTokenEntity.updateStatus(RefreshTokenStatus.INVALID);
log.info(jwtTokenProvider.getMemberId(refreshToken));
// 3. 토큰 생성
TokenInfo tokenInfo = createNewToken(jwtTokenProvider.getMemberId(refreshToken),
refreshTokenEntity.getMember().getPassword()); // createNewToken 부분 ID로 수정
// 4. 저장소에 Refresh Token 저장
saveRefreshToken(tokenInfo, refreshTokenEntity.getMember());
log.info("tokenInfo: {}", tokenInfo);
return tokenInfo;
}
/**
* Refresh Token Rotation 기법 적용
*/
private RefreshToken refreshTokenRotation(String refreshToken) {
log.info("refreshTokenRotation start");
Optional<RefreshToken> token = refreshTokenRepository.findFetchByRefreshToken(refreshToken);
if (token.isEmpty()) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
log.info("token: {}", token);
RefreshToken refreshTokenEntity = token.get();
log.info("refreshTokenEntity: {}", refreshTokenEntity);
if (refreshTokenEntity.getStatus() == RefreshTokenStatus.INVALID) {
// 탈취된 토큰이 활용된 것이므로, 관련 멤버의 모든 Refresh Token을 무효화한다.
// refresh 토큰은 한번만 사용할 수 있도록 한다.
refreshTokenRepository.updateAllByMember(refreshTokenEntity.getMember(), RefreshTokenStatus.INVALID);
}
log.info("end refreshTokenRotation");
return refreshTokenEntity;
}
/**
* 새로운 토큰 생성
*/
private TokenInfo createNewToken(String id, String password) {
log.info("createNewToken start");
// 1. Refresh Token으로 MemberEmail 조회
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(id,
password);
log.info("authenticationToken: {}", authenticationToken);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
log.info("authentication: {}", authentication);
// 2. 새로운 토큰 생성 - refresh token도 새로 생성해서 나가게 된다.
return jwtTokenProvider.generateToken(authentication);
}
/**
* createNewToken 오버로딩
*/
private TokenInfo createNewToken(Long id, String password) {
return createNewToken(id.toString(), password); // id를 string으로 바꿔서 넣도록 함
}
/**
* Refresh Token 저장
*/
private void saveRefreshToken(TokenInfo tokenInfo, Member member) {
log.info("saveRefreshToken start");
RefreshToken newRefreshToken = new RefreshToken(tokenInfo.getRefreshToken(), RefreshTokenStatus.VALID, member);
refreshTokenRepository.save(newRefreshToken);
}
}
무료로 이용할 수 있으면서 이전에 사용해본 경험이 있었기 때문에 MySQL로 선택을 하게 되었고
현재 진행할 프로젝트의 경우에는 AI와 관련된 내용보다는 주로 재무데이터나 가격데이터를 일단위로 활용하기 때문에, 데이터가 많지 않고 데이터간의 연관성이 중요해서 RDBMS를 선택하였다.
2. 외부 접속 계정 추가
create user 'id'@'%' identified by 'password';
FLUSH privileges;
grant all privileges on *.* to 'id'@'%';
FLUSH privileges;
외부 접속을 허용해 줄 계정을 생성하고, 해당 계정에 모든 권한을 부여해 준다.
colab 환경을 사용할 것이기 때문에, 로컬 컴퓨터의 데이터베이스에 접속하기 위해 설정 해 주는 것이다.
간단하게 적어놨지만, 실제로 필자는 해당 과정을 하는데 많은 시간이 소모되었다.
문제점 - 공유기 사용
공유기를 사용하게 되면, 공인ip와 내부ip가 존재하게 되는데 이때 내부ip가 아닌 공인ip를 이용해서 Mysql에 접속해야 하고, MySQL에 접속하기 위한 Port또한 열어 주어야 한다. 그리고 해당 포트에 접근 했을때 특정 내부ip와 연결시키기 위해 pc에서 사용하는 ip를 고정시켜주는 작업도 필요하다. 해당 내용에 대해서 너무 많이 다루게 되면 조금은 포스팅의 내용을 벗어나게 되는 것 같아 구글링을 참고하길 바란다.
또한 공유기를 하나가 아닌 두개 이상을 사용하는 경우가 있다. 필자의 경우에도 skt에서 인터넷을 설치해주고 가며 모뎀을 하나 설치해주고 갔는데, 해당 모뎀은 유선공유기이기 때문에 그것 또한 설정이 필요했다. 무선공유기 하나만 설정해주게 되면 제대로된 접속이 안될 수 있기 때문에 자신이 이용하고 있는 인터넷 통신사에서 설치해준 모뎀을 잘 확인하여 3306포트를 잘 열어주는 것이 필요하다.
2 - 1. MySQL 경로 변경 (필수X)
C드라이버의 용량이 부족한 관계로, 여유분의 SSD에서 데이터베이스를 실행 시키기 위해
경로가 C드라이브로 되어 있던 것을 E드라이브로 변경하려고 한다.
윈도우 작업관리자를 실행하여 먼저 MySQL을 찾아 중지시킨다.
C:\ProgramData\MySQL\MySQL Server 8.0\Data 폴더를 그대로 복사하여 E드라이브 옮긴다.
MySQL 폴더를 만들어 해당 폴더 안에 Data 파일을 복사해준다
C:\ProgramData\MySQL\MySQL Server 8.0\my.ini 파일을 메모장(관리자권한)으로 열어준다
이후 datadir을 찾아서 원래 것을 주석 처리하고 새로 옮긴 곳으로 경로를 저장한다.
그리고 MySQL을 다시 실행시켜 준다.
이렇게 한 뒤 WorkBench를 통해 경로를 확인해 보면
경로가 바뀌어 있는 것을 알 수 있다.
2 - 2. MySQL 데이터 복사 (필수 X)
이 프로젝트는 군대시절 사지방에서 하던 프로젝트로, 예전의 로컬PC에서 서버가 돌아가고 있었다.
전역을 한 뒤 컴퓨터의 파워 문제로 아예 성능 향상을 같이 하기 위해 pc를 바꿨고, 이후 MySQL을 설치하지 않고 있다가 이번에 설치하게 되어 이전PC에 있던 데이터를 ibd파일을 이용해 복사해야 하는 상황이 발생하였다.
하지만 기존 데이터가 없어도 크롤링을 이용해 수집하기 때문에, 이후의 과정만 따라하게 된다면, 새로운 데이터를 수집하는데는 문제가 없을 것이다. (하지만 과거의 데이터까지 이용하는 것이 더 많이 분석할 수 있기 때문에 기존의 데이터를 활용하려고 하는 것이다)
예전 파일을 확인해보면 이렇게 ibd파일이 생성되어 있는 것을 알 수 있다.
하지만 frm파일은 따로 없었기 때문에 테이블의 구조는 colab에 저장해두었던 sql을 이용해 복원하였다.
create table ktbl (
kind varchar(4) Not Null,
k_name varchar(20) Not Null,
PRIMARY KEY (kind)
);
create table comtbl (
c_code char(6) Not Null,
c_name varchar(20) Not Null,
c_kindlarge char(2) Not Null,
c_kindsmall char(4) Not Null,
PRIMARY KEY (c_code),
FOREIGN KEY (c_kindlarge) REFERENCES ktbl (kind),
FOREIGN KEY (c_kindsmall) REFERENCES ktbl (kind)
);
create table ftbl (
c_code char(6) Not Null,
f_date DATE Not Null,
sales BIGINT NOT Null,
gm BIGINT NOT Null,
ni BIGINT NOT Null,
asset BIGINT NOT Null,
ca BIGINT NOT Null,
cl BIGINT NOT Null,
issued_shares BIGINT NOT Null,
bps INT NOT Null,
EPS INT NOT Null,
PRIMARY KEY (c_code, f_date),
FOREIGN KEY (c_code) REFERENCES comtbl (c_code)
);
create table prtbl(
c_code char(6) Not Null,
p_date DATE Not Null,
price int not null,
PRIMARY KEY (c_code, p_date),
FOREIGN KEY (c_code) REFERENCES comtbl (c_code)
);
총 4개의 테이블로 이루어져 있으며, 해당 테이블의 자세한 내용은 해당 게시글의 가장 위 링크를 통해 프로젝트 시작으로 들어가보면 깃허브 링크가 있다, 해당 링크에 colab 문서를 통해서 작성해 둔 조금 더 시각적으로 표현된 markdown이 존재한다.
SET foreign_key_checks = 0;
ALTER TABLE comtbl DISCARD TABLESPACE;
ALTER TABLE ktbl DISCARD TABLESPACE;
ALTER TABLE ktbl DISCARD TABLESPACE;
ALTER TABLE prtbl DISCARD TABLESPACE;
외래 키가 있어서 삭제가 안될 수 있기 때문에, 우선 외래키 확인 조건을 false로 놔두고 ibd파일을 모두 삭제해준다.
기존 파일 -> 현재 경로로 복사
그리고 원래 가지고 있던 파일들을 복사하여 quantdb안에 넣어준다.
ALTER TABLE comtbl IMPORT TABLESPACE;
ALTER TABLE ktbl IMPORT TABLESPACE;
ALTER TABLE ftbl IMPORT TABLESPACE;
ALTER TABLE prtbl IMPORT TABLESPACE;
SET foreign_key_checks = 1;
그리고 옮긴 ibd파일을 적용시킨 후, 외래키 확인 조건을 True로 변경해주면 설정이 끝난다.
이렇게 설정이 끝난 뒤에는 colab에서 sql에 접근 할 수 있게 된다.
이후에는 Worbench는 거의 이용하지 않고 colab을 통해서 데이터를 insert, select 할 것이기 때문에 이번 포스팅은 여기서 마치고 다음 Part2로 찾아오겠습니다.