Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] change login to OIDC #52

Merged
merged 9 commits into from
Nov 20, 2024
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-mail'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// AWS
implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.0.0')
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
Expand Down
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ services:
AWS_REGION: ${AWS_REGION}
AWS_S3_BUCKET: ${AWS_S3_BUCKET}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL}
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION: ${JWT_EXPIRATION}
healthcheck:
test: curl --fail --silent --show-error http://spring:8080/actuator/health || exit 1
interval: 30s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ public class AttendanceService {
private final ParticipantService participantService;

@Transactional
public Participant attend(String memberEmail, Long attendanceId, String qrUuid) {
public Participant attend(Long memberId, Long attendanceId, String qrUuid) {
Member member =
memberRepository
.findByEmail(memberEmail)
.findById(memberId)
.orElseThrow(() -> UserNotFoundException.of(MemberErrorCode.USER_NOT_FOUND));
Attendance attendance = findAttendanceById(attendanceRepository, attendanceId);
attendance.validateActiveQr(qrUuid);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
package gdsc.konkuk.platformcore.application.auth;

import gdsc.konkuk.platformcore.application.member.exceptions.MemberErrorCode;
import gdsc.konkuk.platformcore.application.member.exceptions.UserNotFoundException;
import gdsc.konkuk.platformcore.domain.member.entity.Member;
import gdsc.konkuk.platformcore.domain.member.repository.MemberRepository;
import java.nio.charset.StandardCharsets;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) {}
HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
Member member = memberRepository.findByEmail(oidcUser.getEmail())
.orElseThrow(() -> UserNotFoundException.of(MemberErrorCode.USER_NOT_FOUND));
String token = jwtTokenProvider.createToken(member);

response.addHeader("Authorization", "Bearer " + token);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package gdsc.konkuk.platformcore.application.auth;

import gdsc.konkuk.platformcore.application.member.exceptions.UserNotFoundException;

import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;

import gdsc.konkuk.platformcore.application.member.exceptions.MemberErrorCode;
import gdsc.konkuk.platformcore.domain.member.entity.Member;
import gdsc.konkuk.platformcore.domain.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class CustomOAuthUserService extends OidcUserService {

private final MemberRepository memberRepository;

@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
try {
OidcUser oidcUser = super.loadUser(userRequest);
return processOAuth2User(oidcUser);
} catch (Exception ex) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[질문] Exception은 범위가 조금 큰 것 같습니당 혹시 좁힐 수 있을까요? 아니면 그냥 어떤 에러라도 처리하는게 맞을까요

Copy link
Contributor Author

@goldentrash goldentrash Nov 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서는 저 역시 고민입니다, 하지만 우선 기능상 Critical하지 않고, Filter 특성 상 ControllerAdvice로 처리할 수 없기 때문에 발생 가능한 모든 Case에 대비하려고 합니다.

throw new OAuth2AuthenticationException(
new OAuth2Error("processing_error", "Failed to process user info", null));
}
}

private OidcUser processOAuth2User(OidcUser oidcUser) {
String email = oidcUser.getEmail();
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> UserNotFoundException.of(MemberErrorCode.USER_NOT_FOUND));

return new DefaultOidcUser(
List.of(new SimpleGrantedAuthority(member.getRole().toString())),
oidcUser.getIdToken(),
oidcUser.getUserInfo());
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package gdsc.konkuk.platformcore.application.auth;

import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String token = getJwtFromRequest(request);
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException | IllegalArgumentException e) {
SecurityContextHolder.clearContext();
}

filterChain.doFilter(request, response);
}

private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package gdsc.konkuk.platformcore.application.auth;

import gdsc.konkuk.platformcore.domain.member.entity.Member;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Claims;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

@Value("${jwt.secret}")
private String secretKey;

@Value("${jwt.expiration}")
private long validityInMilliseconds;

@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}

public String createToken(Member member) {
Claims claims = Jwts.claims()
.subject(member.getId().toString())
.add("studentId", member.getStudentId())
.add("email", member.getEmail())
.add("roles", List.of(member.getRole()))
.build();

Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.claims(claims)
.issuedAt(now)
.expiration(validity)
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.compact();
}

public Authentication getAuthentication(String token) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[제안-답할필요X]
메서드의 내용을 보면 token의 claims의 필드를 get하여 Principal에 필요한 데이터로 파싱하고 있네요.
메서드의 인자를 token으로 주는데 claims로 넘기는 것은 어떨까요?

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

public Claims extractClaimsFromToken(String token) {
  return parseClaims(token);
}

public Authentication getAuthenticationFromTokenClaims(Claims claims) {
  authorities = getAuthroritiesFromClaims(claims);
  principal = createPrincipalFromClaims(claims);
  return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

Copy link
Contributor Author

@goldentrash goldentrash Nov 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다! 더해서 실수로 삭제한 코멘트를 반영해서 인증 실패 시 log를 추가했습니다.

[제안 - 답할 필요 X]여기서 Error-catch하면 로그를 남기는건 어떤가요

단, 로그인하지 않은 경우(IllegalArgumentException)에는 별도로 분기해서 log를 남기지 않습니다.

Claims claims = parseClaims(token);

Collection<? extends GrantedAuthority> authorities =
((List<?>) claims.get("roles"))
.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());

Map<String, Object> principal = new HashMap<>();
principal.put("memberId", claims.getSubject());
principal.put("studentId", claims.get("studentId", String.class));
principal.put("email", claims.get("email", String.class));
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseSignedClaims(token)
.getPayload();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@

import gdsc.konkuk.platformcore.application.attendance.dtos.MemberAttendanceQueryDto;
import gdsc.konkuk.platformcore.application.member.dtos.MemberAttendances;
import gdsc.konkuk.platformcore.application.member.exceptions.UserNotAllowedException;
import gdsc.konkuk.platformcore.application.member.exceptions.UserPasswordInvalidException;
import gdsc.konkuk.platformcore.domain.member.entity.MemberRole;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
Expand All @@ -17,7 +14,6 @@
import gdsc.konkuk.platformcore.domain.attendance.entity.Participant;
import gdsc.konkuk.platformcore.domain.attendance.repository.AttendanceRepository;
import gdsc.konkuk.platformcore.domain.attendance.repository.ParticipantRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -36,8 +32,6 @@
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
private final AttendanceRepository attendanceRepository;
private final ParticipantRepository participantRepository;
Expand All @@ -50,24 +44,6 @@ public Member register(MemberRegisterRequest registerRequest) {
return memberRepository.save(MemberRegisterRequest.toEntity(registerRequest));
}

@Transactional
public void changePassword(String studentId, String password) {
Member member =
memberRepository
.findByStudentId(studentId)
.orElseThrow(() -> UserNotFoundException.of(MemberErrorCode.USER_NOT_FOUND));

if(!member.isPasswordCorrect("")) { // 비밀번호가 초깃값인지 확인
throw UserPasswordInvalidException.of(MemberErrorCode.USER_PASSWORD_INVALID);
}
if(member.getRole() != MemberRole.CORE) { // 우선은 관리자만 비밀번호 변경 허용
throw UserNotAllowedException.of(MemberErrorCode.USER_NOT_ALLOWED);
}

String encodedPassword = passwordEncoder.encode(password);
member.updatePassword(encodedPassword);
}

@Transactional
public void withdraw(Long currentId) {
Member member =
Expand Down
Loading