처음 프로젝트를 진행할 때 @Entity User Class에 UserDetails, OAuth2User를 같이 구현했다.
동작하는데는 문제가 없긴했지만 하나의 파일에 여러가지를 한꺼번에 구현하다보니 필요하지도 않은 주석을 사용해야하기도 했고, 조금 헷갈리기도 했다. 그래서 하나의 파일에 한번에 구현했던 처음을 후회하면 파일을 분리하기로 했다.
@Getter
@Setter
@AllArgsConstructor
@RequiredArgsConstructor
@Builder
@Entity
public class User implements UserDetails, OAuth2User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@JsonIgnore
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String realName;
@Column(nullable = false)
private LocalDate birth;
@Column(nullable = false)
private Boolean gender;
@Column(nullable = false)
private String phoneNumber;
@Column(nullable = false)
private String email;
@JsonIgnore
@Transient
private Map<String, Object> attributes;
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(role.getName()));
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
@JsonIgnore
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@JsonIgnore
@Override
public String getName() {
return attributes.get("name").toString();
}
}
이 파일이 기존에 구현했던 User.java 파일이다. @JsonIgnore, @Transient 주석의 경우 처음부터 파일을 분리했다면 사용할 일이 없는 주석이다. 심지어 오랜만에 코드를 보니 @JsonIgnore 주석은 왜 사용했는지조차 기억이 안난다.. 하나의 파일에 몽땅 구현했기 때문에 사용했다는 것은 알고 있었기 때문에 일단 분리하면 사용하지 않아도 되니 분리하기로 한다.
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String realName;
@Column(nullable = false)
private LocalDate birth;
@Column(nullable = false)
private Boolean gender;
@Column(nullable = false)
private String phoneNumber;
@Column(nullable = false)
private String email;
@OneToMany(mappedBy = "sender", fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE })
private List<Message> messages;
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE })
private List<ChatRoom> userChatRooms;
@OneToMany(mappedBy = "admin", fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE })
private List<ChatRoom> adminChatRooms;
}
본래 User.java 파일은 Entity 즉 DB Table과 연결되는 파일이다. 그래서 JPA와 Hibernate만을 사용하여 설정한다.
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getName()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
public Role getRole() {
return user.getRole();
}
}
UserDetails, OAuth2User를 구현하는 파일은 DTO처럼 사용되기 때문에 굳이 @Bean으로 등록하여 객체를 관리해줄 필요가 없다. 그래서 @Component주석을 사용해줄 필요가 없다. 그래서 한번 생성된 DTO UserDetails의 필드객체가 바뀔 일이 없도록 final로 선언하고 생성자로 초기화해준다.
public class CustomOAuth2UserDetails implements OAuth2User {
private final User user;
private final Map<String, Object> attributes;
public CustomOAuth2UserDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getName()));
}
@Override
public String getName() {
return attributes.get("name").toString();
}
}
OAuth2User도 동일하다.
이제 실제로 인증 로직을 구현하고 있는 AuthService.java에서 자료형이 일치하지 않아 오류가 발생할 것이다.
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("user not found with username: " + username));
String requestURI = httpServletRequest.getRequestURI();
if (requestURI.contains("/signin")) {
return new CustomUserDetails(
User.builder()
.username(user.getUsername())
.password(user.getPassword())
.role(user.getRole())
.build());
}
return new CustomUserDetails(user);
}
사실 별거없다. 자료형이 일치하도록 반환해주면 된다. 해당 메소드는 UserDetailsService 인터페이스를 구현할 때 필수로 구현해야하는 메소드이다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest);
return processGoogleLogin(oAuth2User, userRequest);
}
@Transactional(readOnly = true)
private OAuth2User processGoogleLogin(OAuth2User oAuth2User, OAuth2UserRequest userRequest) {
String accessToken = userRequest.getAccessToken().getTokenValue();
String phoneNumber = fetchGooglePhoneNumber(accessToken);
Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());
attributes.put("phone_number", phoneNumber);
String realName = attributes.get("name").toString();
httpServletRequest.setAttribute("realName", realName);
httpServletRequest.setAttribute("phoneNumber", phoneNumber);
User user = userRepository.findByRealNameAndPhoneNumber(realName, phoneNumber)
.orElseThrow(() -> new OAuth2AuthenticationException("DB에 일치하는 유저가 없습니다."));
return new CustomOAuth2UserDetails(user, attributes);
}
private String fetchGooglePhoneNumber(String accessToken) {
String phoneNumber = null;
try {
RestTemplate restTemplate = new RestTemplate();
String url = "https://people.googleapis.com/v1/people/me?personFields=phoneNumbers";
String bearerToken = "Bearer " + accessToken;
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", bearerToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
String body = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(body);
JsonNode phoneNumbersNode = rootNode.path("phoneNumbers");
if (phoneNumbersNode.isArray() && phoneNumbersNode.size() > 0) {
JsonNode firstPhoneNumberNode = phoneNumbersNode.get(0);
phoneNumber = firstPhoneNumberNode.path("value").asText();
}
} catch (Exception e) {
log.error("Error fetching phone number", e);
}
return phoneNumber;
}
}
loadUser 메소드는 OAuth2UserService 인터페이스를 구현할 때 필수로 구현해야하는 메소드이다. 이 또한 올바른 자료형을 반환하도록 해준다.
실행시켜보니 JwTokenProvider에서 오류가 발생했다. createToken 메소드의 authentication.getPrincipal() 메소드의 반환자료형이 UserDetails인데 일치하지 않아서 오류가 발생한 것이다.
private String createToken(Authentication authentication) {
Object principal = authentication.getPrincipal();
if (principal instanceof CustomUserDetails) {
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
return buildJwToken(userDetails.getId().toString());
} else if (principal instanceof CustomOAuth2UserDetails) {
CustomOAuth2UserDetails userDetails = (CustomOAuth2UserDetails) authentication.getPrincipal();
return buildJwToken(userDetails.getId().toString());
} else {
throw new IllegalArgumentException("Invalid principal type");
}
}
private String buildJwToken(String subject) {
return Jwts.builder()
.subject(subject)
.issuer(issuer)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(secretKey)
.compact();
}
해당 코드도 자료형만 변경. 이걸로 끝이다.
(2024-10-07 추가) 인증 객체를 둘로 나누니 인증이 필요한 모든 과정을 두개로 나누어 각각의 경우에 대해 처리를 해주어야하는게 너무 번거로워서 합치기로 했다.
public class PrincipalDetails implements UserDetails, OAuth2User {
private final User user;
private final Map<String, Object> attributes;
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getName()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return attributes.get("name").toString();
}
public Role getRole() {
return user.getRole();
}
public Long getId() {
return user.getId();
}
public User getUser() {
return user;
}
}
어차피 둘 다 인증 정보를 전달하는 용도로 사용되기 때문에 하나의 클래스에 구현해도 문제없을 것이라고 판단했다. attributes의 상태는 저장할 필요가 없고 만약 필요하다면 인증 로직 중 필요한 부분만 DB에 저장하는 것이 좋을 것 같다.