본문 바로가기

Java & Spring

Spring Security6 - OAuth2 Authorization Server 삽질일기 (1편: OAuth2&CustomUser)

반응형

 

시작하기에 앞서 정말 수많은 삽질이 있었다.
참... 알고보면 별것도 아닌데... 

 

 

****************************************  (시간이 없으신 분들은 아래 구간 스킵) ****************************************

 

세상이 참 좋아지면서 생성형 AI들의 도움을 많이 받기 시작했다.

물론 습득을 위해서는 AI만 의존하는 것보다 직접 구글링을 하는 게 더 좋다는 이야기도 들어서 새로운 기술은 최대한 공식문서를 이용하려고 하는 편이다.

최근들어 GPT의 할루시네이션에 진절머리가 나서 한동안 잘 안쓰다가 cursor ide가 꽤 괜찮다는 이야기에 자주 노출되다 보니 한 번 사용해보기로 했다.

 

실제로 사용해보니.. 오 신세계... Chat만 제공하는게 아니라 직접 코딩을 해준다.. 코파일럿 처음 볼 때도 대박이다 싶었지만 점차 도움이 안되는 느낌이었는데.. 이놈은 COMPOSER를 통해 Nextjs14 버전 샘플 만드는데 꽤 도움을 주었다. 거기에 ai 선택지도 넓어서 GPT3.5나 o4가 멍청한 소리하는 것 때문에 받았던 스트레스가 좀 가시는 느낌이었다. 

처음 가입하면 GPT o1-mini도 무료로 사용해볼 수 있게 제공해주기 때문에 claude랑 입맛에 맞게 체험을 해봤는데 진짜 많은 도움이 되었다.

여기까지 했어야 했다.. 

 

와.. 또 된통 당했다 ㅋㅋㅋㅋㅋ 아직 AI는 구글링해도 잘 나오지 않는 정보나 공식문서가 빈약한 경우, 이슈가 나서 커뮤니티에 논란이 되는 내용들에 대해서는 너무 약한 것 같다... 그럴싸하게 헛소리를 해놔서... 진짜 방향을 완전 잘못잡게 만들어버린다...

 

더군다나 내 목표였던 Spring Authorization Server + Resource Server 구축과 같은 경우는 진짜.... 자세히 설명을 해도 자꾸 OAuth2 Client Server 만드는 것만 내뱉고...

(뭐 이건 구글링도 마찬가지인게 보통은 이미 구축된 인증/인가 서버를 쓰는 사람이 압도적으로 많으니까... 이해는한다...)

 

다음 글에 또 작성하겠지만 SpringDoc(Swagger) 설정도... 진짜...

버전업되면서 옛날 정보 알려주는 건 괜찮다. 버전 명시했는데도 옛날정보 주는것도 이해할 수 있다.. 그건 내가 구버전을 다 만져봤으니까 분간할 수 있다. 그런데 이상한 소설써가지고 와서 방향성을 완전 헷갈리게 만들어버리는 경우는... 진짜 쉽지 않다... 시간을 줄여준 것보다 더 쓰게 만든 것 같다... 너무 많은 정신력과 시간을 써서 좀 한탄을 남겨봤다.. 잡설은 여기까지...

 

****************************************  (시간이 없으신 분들은 윗 구간 스킵) ****************************************  

 

1. 도입과정

예전에는 Spring Security에서 더이상 Authorization Server를 제공하지 않는다고 들었던 기억이 난다. 물론 찾는 사람들이 꽤 있어 Security에서 나와 별도의 Dependency를 추가하게끔 되었지만, 그땐 충격이었다.. 덕분에 Keycloak 공부도 좀 해서 도움은 되었지만... 라이브러리들을 합쳐놓은 거긴해도 Spring안에서 다 놀다가 갑자기 중단한다니 슬펐다... 그런데 이제 다시 완전체가 된 기분이다! (이후에는 SAML idp도 해봐야지! 하고 로드맵 잡았는데.. 이젠 SAML idp는 찾는 사람 별로 없어서 또 최근에 빠졌네... 별도로 가져와서 구현하거나, Spring Boot 버전을 좀 낮추거나 해야겠다... 이건... ㅠ)

 

아무튼 찾는 사람이 적으니 이런 상황이 나오는 것 같아 삽질과 해결과정을 공유해 나와 비슷한 상황인 분들은 해결이 됐으면 싶었다..

 

2. 구성

간단한 구성도를 그려보자면 아래와 같다. 뭐 막상하고 보면 별건 없었다.

 

사실 별거 없긴한데, 혼자서 개인 홈서버 만드는 것치고는 꽤 많은 요소들이 있는 것 같다는 생각도 든다.

요즘 클라우드 좋다 좋다 하지만 그래도 개인 테스트용 홈서버는 못참지...ㅎㅎ (광군제,블프까지 불을 질러버리니 어쩔 수 없었따....)

 

여기서 중요한 삽질은 Spring Security에서 UsernamePasswordAuthenticationToken에 CustomUser 객체를 넣으면서 부터 시작이 되었다.

 

3. AuthenticationProvider 그리고 CustomUser(or CustomUserDetails)

우선 Authorization Server에는 OAuth2+OIDC와 FormLogin(Session)을 모두 사용하기로 했으며, 추가적으로 email & password로 로그인을 하지만 Authentication의 getName()에user의 id값이 나오길 원했다. 그에 나는 AuthenticationProvider(이걸 쓰면 가능.)를 직접 구현하기로 했는데, 목표는 CustomUser를 Authentication의 Principal 정보에서 꺼내볼 수 있도록 하는 것. 여기서 중간에 잠깐 포기할까 했다가 결국은 해결을 한 사건을 만난다.

어딜 찾아봐도 OAuth2 Login을 쓸 때는 CustomUser를 쓰면 에러의 연속이라 안쓰는게 낫다고 한다..

 

구글링을 해도 AI 답변도 github의 이슈를 보아도 슈팅이 되질 않았다.
(mixin 설정, ObjectMapper 직렬화 커스텀 등등....)

 

 

이때부터였다... AI 답변도 같은 헛소리만 주구장창... 할루시네이션 파티.... 도저히 열받아서 못쓰겠다고 직접 찾아보는데... 오 웬걸? 개발자 유미님이란 분이 딱 때마침 나와 비슷한 작업을 Youtube에 올리셨다. 한번 쭉 돌렸는데... 이분도 나와 같은 결론을 내리셨다.. (매우 친절하게 세세한 부분까지 영상을 올려주셨으니 Spring Security의 인증/인가, OAuth2, JWT에 관심이 있으시면 한 번 둘러보시길 추천드린다..)

 

간단한 세팅 코드를 공유하자면 아래와 같다.

// AuthenticationProvider
public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    	// 생략...
    	return new UsernamePasswordAuthenticationToken(account.toDto(), account.getPassword(), account.getAuthorities());
        // 여기서 toDto()는 Principal을 구현한 CustomUser
        // account.getAuthorities()를 유심히 봐두자..
	}
	@Override
	public boolean supports(Class<?> authentication) { //어떤 토큰을 인증할건지
		return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class);
	}
}

// SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	// 생략...
    
    @Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		
		return http
        	// 중략...
        	.authenticationProvider(authenticationProvider()) // 여기!
            .formLogin(form -> 
                form
                    .loginPage("/login")
                    .defaultSuccessUrl("/me")
                    .successHandler(authSuccessHandler())
                    .failureHandler(authFailureHandler())
                    .usernameParameter("email")
                    .passwordParameter("password")
                    .permitAll()
            )
            .logout(logout ->
                logout
                    .logoutUrl("/logout")
                    .logoutSuccessUrl("/login")
                    .invalidateHttpSession(true)
                    .clearAuthentication(true)
                    .deleteCookies("JSESSIONID") 
                    .permitAll()
            )
            .sessionManagement(session ->
                session // AuthenticationException
                    .sessionFixation().changeSessionId()
                    .invalidSessionUrl("/login?error=true&r=InvalidSession")
                    .maximumSessions(1) // 안되면 UserDetails나 Principal 구현체에 equals, hashcode 정의했는지 확인...
                    .maxSessionsPreventsLogin(false) // false : 기존 세션 로그아웃, true : 신규 세션 block
                    .expiredUrl("/login?error=true&r=ExpiredSession")
            )
            .exceptionHandling(exception -> 
                exception // AccessDeniedException
                    .authenticationEntryPoint(authenticationEntryPoint("/login?error=true&r=Unauthorized"))
                    .accessDeniedPage("/login?error=true&r=Forbidden")
            )
            .build();   
    }
}

 

4. 쌩뚱맞게 극적인 해결

그렇게 포기하고 기존 User 객체를 new해서 토큰정보에 넣으려고 했다..

그렇게 마지막으로 에러 로그를 읽는 중에... UsernamePasswordAuthenticationToken을 Deserialize 하는데 문제가 발생했다는 곳에 갑자기 뭔가 팍 꼽혀서.... 문득 예전 프로젝트에서 사용했던 @JsonDeserialize @JsonSerialize 어노테이션들이 생각이 났다... 에이 안되면 말고 이것만 해보고 그냥 new User(...)으로 넘기자 하고 마지막 시도를 해보았다...

 

그런데 그냥 갑자기 해결이 되어 버렸다... 구글링, AI, 커뮤니티, 강의까지 다 찾아봐도 안되던게.. 그냥 무심코 넣은 어노테이션이 해결을 해주었다... 도대체 mixin 작업 같은건 왜했을까... 예전에는 그걸로 해결이 됐던건가 생각을하며 commit을 했다...ㅠㅠ

 

아래는 내가 사용한 Dto를 정리한 내용이다...

// AccountDto (Principal)
@Getter 
@EqualsAndHashCode(of = "id") // session 동시접근제어를 위해서는 필수
@JsonDeserialize
@JsonIgnoreProperties(ignoreUnknown = true)
@NoArgsConstructor @AllArgsConstructor @Builder
public class AccountDto implements Principal {
	private String id;
	private String email;
	// 중략...
	private List<? extends GrantedAuthority> authorities;
	
    @Override
	public String getName() {
		return this.id;
	}
}

// Account
@Entity 
@DynamicInsert @DynamicUpdate
@Table(name = "TBL_ACCOUNT")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Account extends BaseEntity {

	@Id @GeneratedValue(strategy = GenerationType.UUID) @Column(length = 36)
	private String id;
	@Column(unique = true, nullable = false, length = 50)
	private String email;
	@JsonIgnore
	private String password;
	@ManyToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	@JoinColumn(name = "organizationId")
	private Organization organization;
	@Convert(converter = GrantedAuthorityListConverter.class)
	private List<String> authorities;
	// 중략...
    
	public List<? extends GrantedAuthority> getAuthorities() {
		return authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
	}
	public User toUser() {
		return new User(id, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, getAuthorities());
	}
	public AccountDto toDto() {
		return AccountDto.builder()
				.id(id)
				.email(email)
				.organizationId(organization.getId())
				.authorities(getAuthorities())
				.displayName(displayName)
				.accountNonExpired(accountNonExpired)
				.accountNonLocked(accountNonLocked)
				.credentialsNonExpired(credentialsNonExpired)
				.enabled(enabled)
				.build();
	}
}

 

여기서 잠깐 또 문제가 있었는데... CustomUser 객체를 사용하면서, 갑자기 동시 로그인 방지를 위해 적용했던 .maximumSessions(1)이 동작을 하지 않는 것이다!

하지만 이거 뭔가 로그인한 User 정보를 뭘로 비교하지 싶어 찾아보니 내가 equals랑 hashCode를 생각도 안하고 있었네?

우리에겐 롬복이 있으니 간단하게 클리어! ㅎㅎㅎ 너무 기뻤다 ㅎㅎㅎㅎㅎㅎㅎ

 

아.. 참고로 getAuthorities()에 있는 .collect(Collectors.toList()) 부분...
그냥 .toList()쓰면 불변객체라 다른 에러를 보게될테니 꼭 뮤터블한 list로 반환해주자..
AuthenticationProvider에서 account.getAuthorities()를 유심히 봐두라고 한 이유...

 

 

5. 마무리

막상 별거 없는데... 고생한 시간이 아까워 주저리주저리 말이 길어졌다... 여기까지 읽어주신 분이 있다면 그저 감사ㅠㅠㅠ

사실 시간이랑 열받은거로 보면 별거 많았다.. 특히 진실에 가까워질수록 AI가 자꾸 트롤짓해서 조사방향을 다른 곳으로 틀어버렸다....AI 다루는 것도 스킬은 스킬인가보다..ㅠㅠ

 

 

사실 5편정도로 쪼개도 될 내용인데... 아무튼 에러로그를 올려버리면 글이 너무 길어져버리니까.... 그럼 2편 or 3편으로 다시 돌아오겠다... 다음에는 여기 서버에 SpringDoc(Swagger) 적용과 IpBlackListFilter 그리고 OAuth2인증/인가를 반영하며 겪은 삽질일기로 돌아오겠다.....

 

그럼 오늘은 여기까지...