Skip to content

enyo9rt/NewsCommunity-bFinal

 
 

Repository files navigation

💚 할머니는 다 들어주셔

스포츠 뉴스 커뮤니티


1. 제작 기간 & 참여 인원

  • 2022년 6월 24일 ~ 7월 29일
  • 4인 팀 프로젝트

2. 사용 기술

Back-end

  • Java 11
  • Spring Boot 2.7.2
  • Gradle 7.4.1
  • Spring Data JPA
  • MySQL 8.0.28
  • Spring Security

Front-end

  • HTML5
  • CSS3
  • Javascript
  • Bulma
  • BootStrap
  • JQuery

3. 서비스 소개

네이버의 스포츠 뉴스를 스크래핑하여 클로바 요약 API로 각 뉴스를 요약해 보여줍니다.
회원들이 뉴스에 대한 의견을 댓글로 나눌 수 있습니다.
마음에 드는 뉴스를 북마크하거나 다른 회원의 댓글과 북마크를 모아볼 수 있습니다.

상세 정보 보기
API 명세서 보기

default.mp4

4. ERD 설계


5. 담당 기능

인증과 더불어 회원과 관련된 기능 (회원가입, 프로필 등) 을 담당했습니다.
아래의 토글 항목에서 주된 기능들의 코드와 간략한 설명을 보실 수 있습니다.

회원 생성


  • 트랜잭션으로 회원 객체를 저장할 때 기본 권한과 프로필을 함께 저장합니다.
    public String signUp(SignupRequestDto dto) throws HibernateException {
    User user = new User(dto);
    saveUser(user);
    Role role = getRole(new Role(RoleType.USER).getName());
    try {
    if (role == null) {
    saveRole(new Role(RoleType.USER));
    }
    addRoleToUser(user.getUsername(), RoleType.USER);
    // 기본 프로필 추가
    defaultProfile(user);
    } catch (DataIntegrityViolationException | ConstraintViolationException e) {
    log.error("Faild to sign up");
    throw new HibernateException("Failed to add role or profile to user cause=", e.getCause());
    }
    return "success";
    }


인증

  • Spring Security를 사용하여 필터에서 처리합니다.
    html form으로 입력받은 값을 HttpServletRequest 객체에서 가져옵니다.
    dto 객체를 통해 유효성 검증 후 UserDetailsService에 전달하여 조회하고 UserDetails 인터페이스를 구현한 User 객체를 생성합니다. UsernamePasswordAuthenticationToken을 생성, AuthenticationManager에 전달합니다.
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    UsernamePasswordAuthenticationToken authenticationToken = null;
    try {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    SigninRequestDto requestDto = new SigninRequestDto(username, password);
    User user = (User) userDetailsService.loadUserByUsername(requestDto.getUsername());
    authenticationToken = new UsernamePasswordAuthenticationToken(user, requestDto.getPassword());
    } catch (IllegalArgumentException e) {
    throw AuthException.builder()
    .message(e.getMessage())
    .code("A401")
    .build();
    } catch (NullPointerException e) {
    throw AuthException.builder()
    .message("올바르지 않은 아이디 혹은 비밀번호입니다.")
    .code("A401")
    .build();
    }
    return authenticationManager.authenticate(authenticationToken);
    }

  • 인증에 성공하면 JWT를 발급합니다.
    토큰은 접근 토큰과 갱신 토큰을 발급하며, 사용자 ID와 함께 DB에 저장됩니다.
    다른 기능에서 인증된 사용자 ID를 필요로 하는 경우 사용할 수 있도록 헤더에 사용자 ID도 함께 반환합니다.
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
    User user = (User) authentication.getPrincipal();
    Algorithm algorithm = Algorithm.HMAC256("secretKey".getBytes());
    String access_token = JWT.create()
    .withSubject(user.getUsername())
    .withExpiresAt(new Date(System.currentTimeMillis() + 60 * 60 * 1000))
    .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
    .sign(algorithm);
    String refresh_token = JWT.create()
    .withSubject(user.getUsername())
    .withExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000))
    .sign(algorithm);
    String username = user.getUsername();
    Tokens existingTokens = tokensRepository.findByUsername(username);
    if (existingTokens == null) {
    // 유저가 토큰 정보를 가지고 있지 않으면 생성 후 DB 저장
    Tokens newTokens = Tokens.builder()
    .username(username)
    .accessToken(access_token)
    .refreshToken(refresh_token)
    .build();
    tokensRepository.save(newTokens);
    } else {
    // 유저가 토큰 정보를 가지고 있으면 변경 후 DB 저장
    existingTokens.update(access_token, refresh_token);
    tokensRepository.save(existingTokens);
    }
    // 응답 헤더에 토큰과 사용자 ID 추가
    byte[] usernameHeader = username.getBytes(StandardCharsets.UTF_8);
    response.setHeader("token", access_token);
    response.setHeader("username", Base64.getEncoder()
    .encodeToString(usernameHeader));
    ResponseCookie refresh = ResponseCookie.from("ref_uid", refresh_token)
    .maxAge(7 * 24 * 60 * 60)
    .httpOnly(true)
    .secure(true)
    .sameSite("None")
    .path("/")
    .build();
    response.setHeader(SET_COOKIE, refresh.toString());
    response.setContentType(APPLICATION_JSON_VALUE);
    new ObjectMapper().writeValue(response.getOutputStream(), "success");
    }
    }


인가

  • Spring Security를 사용하여 필터에서 처리합니다.
    접근 토큰을 풀어 사용자의 정보를 확인하고 DB에 저장된 토큰 값으로 재확인 합니다.
    정상적인 접근이라면 UserDetails 인터페이스를 구현한 User 객체와 권한으로 UsernamePasswordAuthenticationToken을 생성합니다.
    SecurityContext에 보관합니다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // 인가 과정을 거칠 필요가 없는 요청
    if (request.getServletPath().equals("/api/login") ||
    request.getServletPath().startsWith("/api/signup") ||
    request.getServletPath().equals("/api/token/refresh")) {
    filterChain.doFilter(request, response);
    } else {
    // 접근 토큰이 있으면 요청에 필요한 권한이 있는지 확인
    String authorizationHeader = request.getHeader(AUTHORIZATION);
    if (authorizationHeader != null && authorizationHeader.startsWith(AuthConstants.TOKEN_TYPE)) {
    try {
    String access_token = authorizationHeader.substring(AuthConstants.TOKEN_TYPE.length());
    Algorithm algorithm = Algorithm.HMAC256("secretKey".getBytes());
    JWTVerifier verifier = JWT.require(algorithm).build();
    DecodedJWT decodedJWT = verifier.verify(access_token);
    String username = decodedJWT.getSubject();
    // 해당 유저의 허용된 토큰값과 비교
    Tokens tokens = tokensRepository.findByUsername(username);
    if (tokens == null)
    throw AuthException.builder().message("허용 토큰 정보를 찾을 수 없습니다.").invalidValue("사용자 ID: " + username).code("A403").build();
    String allowedToken = tokens.getAccessToken();
    if(!allowedToken.equals(access_token))
    throw AuthException.builder().message("허용된 접근 토큰이 아닙니다.").invalidValue("접근 토큰: " + access_token).code("A404").build();
    String[] roles = decodedJWT.getClaim("roles").asArray(String.class);
    Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
    stream(roles).forEach(role -> {authorities.add(new SimpleGrantedAuthority(role));});
    User user = (User) userDetailsService.loadUserByUsername(username);
    UsernamePasswordAuthenticationToken authenticationToken =
    new UsernamePasswordAuthenticationToken(user, null, authorities);
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    filterChain.doFilter(request, response);
    } catch (TokenExpiredException e) {
    throw AuthException.builder().message("접근 토큰이 만료되었습니다.").code("A406").build();
    } catch (JWTVerificationException e) {
    throw AuthException.builder().message("올바른 토큰이 아닙니다.").code("A402").build();
    }
    } else {
    filterChain.doFilter(request, response);
    }
    }
    }
    }


프로필 수정

  • 인증된 사용자 ID로 기존 프로필 정보를 찾고, 입력받은 프로필 정보로 변경합니다.
    프로필 사진은 aws sdk 라이브러리를 사용하여 s3에 업로드합니다.
    public String updateProfile(String username, ProfileRequestDto requestDto) {
    UserProfile existingProfile = getUser(username).getProfile(); // 해당 사용자의 기존 프로필 찾기
    if (existingProfile == null) throw InvalidRequestException.builder()
    .message("사용자의 프로필을 찾을 수 없습니다.")
    .invalidValue("사용자 ID: " + username)
    .code("U401")
    .build();
    MultipartFile file = requestDto.getFile();
    if (file != null) {
    isImage(file); // 파일이 이미지인지 확인
    // 버킷에 저장될 경로, 파일명 그리고 파일의 metadata 생성
    String path = String.format("%s/%s", bucketName, username);
    String fileName = String.format("%s", file.getOriginalFilename());
    Map<String, String> metadata = extractMetadata(file);
    try {
    fileStore.save(path, fileName, Optional.of(metadata), file.getInputStream()); // 변경 파일 저장
    } catch (IOException e) {
    throw new IllegalStateException("프로필 사진 저장에 실패했습니다.", e.getCause());
    }
    }
    // 프로필 변경 사항 적용 후 DB 저장
    existingProfile.update(requestDto);
    profileRepository.save(existingProfile);
    return "success";
    }
    }



6. 트러블 슈팅

6.1 핵심 트러블 슈팅

  • 로그인 사용자의 정보 가져오기 실패
    UserDetails가 null을 반환하는 문제
    댓글 등 다른 기능에서 사용자의 정보가 필요하여 @AuthenticationPrincipal 어노테이션을 사용하여 SecurityContext의 UserDetails 객체를 가져오려고 했습니다.
    Authentication 객체의 Principal을 가져오는 과정에서 기존에는 username 자체, 즉 String을 넣었기 때문에 null이 반환되었습니다.
    이를 UsernamePasswordAuthenticationToken 객체를 생성할 때 UserDetails를 구현한 User 객체를 넣어줌으로써 해결하였습니다.

  • 비로그인 사용자 서비스 원활히 이용 불가
    비로그인 사용자의 경우 principal이 null을 반환하여 오류 발생하는 문제
    이 서비스의 경우, 비로그인 사용자도 조회 기능을 제한 없이 이용할 수 있도록 하고자 했습니다.
    그러나 @AuthenticationPrincipal 어노테이션을 사용하여 사용자의 정보를 필요로 하는 조회 API의 경우 anonymousUser 문자열을 반환하게 되어 오류가 발생했습니다.
    이를 해당 어노테이션을 커스텀하여 비로그인 사용자의 경우 null을 반환하도록 함으로써 해결하였습니다.


6.2 그 외 트러블 슈팅

회원 ID 중복 조회 불가
  • 중복 조회 url이 SecurityConfig 내 permitAll() 누락되어 추가하여 해결
필터 예외 처리 방식
  • 컨트롤러와 통일되지 않아 ExceptionHandlerFilter 클래스 생성, 커스텀 예외를 발생시켜 해결
간헐적으로 로그인, 로그아웃이 제대로 이루어지지 않는 문제
  • AJAX에 async 옵션을 주어 동기식 처리로 해결

7. 회고 / 느낀점

최종 프로젝트 회고

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Java 100.0%