반응형
유저 기능
- 회원가입, 로그인, 정보 수정, 회원 탈퇴, 마이 페이지
- 자세한 기능 설계는 [Spring Boot] 게시판 만들기 1 - 설계 & 결과 참고
UserRepository
- findAllByNicknameContains() : 닉네임에 String이 포함되어 있는지 => ADMIN이 User 검색 시 사용
- existsByLoginId(), existsByNickname() : 로그인 아이디, 닉네임을 가진 유저가 존재하는지 => 회원 가입 시 중복 체크용으로 사용
- countAllByUserRole() : 해당 등급을 가진 유저가 몇명 있는지 => 홈 화면에서 출력하기 위해 사용
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByLoginId(String loginId);
Page<User> findAllByNicknameContains(String nickname, PageRequest pageRequest);
Boolean existsByLoginId(String loginId);
Boolean existsByNickname(String nickname);
Long countAllByUserRole(UserRole userRole);
}
UserService
- joinValid(), editValid()는 회원 가입, 정보 수정 시 비어있는지, 글자수가 초과하는지, 중복되는지 등을 상황과 변수에 맞게 판단 후 하나라도 조건을 만족하지 못하면 BindingResult를 return 함으로써 회원가입, 정보 수정을 못하게 함
- delete()는 유저를 삭제하는 메소드인데, DB에서 삭제하기 전 해당 유저가 추가한 댓글, 좋아요를 조회하여 Board의 likeCnt, commentCnt를 직접 수정
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final LikeRepository likeRepository;
private final CommentRepository commentRepository;
private final BCryptPasswordEncoder encoder;
public BindingResult joinValid(UserJoinRequest req, BindingResult bindingResult)
{
if (req.getLoginId().isEmpty()) {
bindingResult.addError(new FieldError("req", "loginId", "아이디가 비어있습니다."));
} else if (req.getLoginId().length() > 10) {
bindingResult.addError(new FieldError("req", "loginId", "아이디가 10자가 넘습니다."));
} else if (userRepository.existsByLoginId(req.getLoginId())) {
bindingResult.addError(new FieldError("req", "loginId", "아이디가 중복됩니다."));
}
if (req.getPassword().isEmpty()) {
bindingResult.addError(new FieldError("req", "password", "비밀번호가 비어있습니다."));
}
if (!req.getPassword().equals(req.getPasswordCheck())) {
bindingResult.addError(new FieldError("req", "passwordCheck", "비밀번호가 일치하지 않습니다."));
}
if (req.getNickname().isEmpty()) {
bindingResult.addError(new FieldError("req", "nickname", "닉네임이 비어있습니다."));
} else if (req.getNickname().length() > 10) {
bindingResult.addError(new FieldError("req", "nickname", "닉네임이 10자가 넘습니다."));
} else if (userRepository.existsByNickname(req.getNickname())) {
bindingResult.addError(new FieldError("req", "nickname", "닉네임이 중복됩니다."));
}
return bindingResult;
}
public void join(UserJoinRequest req) {
userRepository.save(req.toEntity( encoder.encode(req.getPassword()) ));
}
public User myInfo(String loginId) {
return userRepository.findByLoginId(loginId).get();
}
public BindingResult editValid(UserDto dto, BindingResult bindingResult, String loginId)
{
User loginUser = userRepository.findByLoginId(loginId).get();
if (dto.getNowPassword().isEmpty()) {
bindingResult.addError(new FieldError("dto", "nowPassword", "현재 비밀번호가 비어있습니다."));
} else if (!encoder.matches(dto.getNowPassword(), loginUser.getPassword())) {
bindingResult.addError(new FieldError("dto", "nowPassword", "현재 비밀번호가 틀렸습니다."));
}
if (!dto.getNewPassword().equals(dto.getNewPasswordCheck())) {
bindingResult.addError(new FieldError("dto", "newPasswordCheck", "비밀번호가 일치하지 않습니다."));
}
if (dto.getNickname().isEmpty()) {
bindingResult.addError(new FieldError("dto", "nickname", "닉네임이 비어있습니다."));
} else if (dto.getNickname().length() > 10) {
bindingResult.addError(new FieldError("dto", "nickname", "닉네임이 10자가 넘습니다."));
} else if (!dto.getNickname().equals(loginUser.getNickname()) && userRepository.existsByNickname(dto.getNickname())) {
bindingResult.addError(new FieldError("dto", "nickname", "닉네임이 중복됩니다."));
}
return bindingResult;
}
@Transactional
public void edit(UserDto dto, String loginId) {
User loginUser = userRepository.findByLoginId(loginId).get();
if (dto.getNewPassword().equals("")) {
loginUser.edit(loginUser.getPassword(), dto.getNickname());
} else {
loginUser.edit(encoder.encode(dto.getNewPassword()), dto.getNickname());
}
}
@Transactional
public Boolean delete(String loginId, String nowPassword) {
User loginUser = userRepository.findByLoginId(loginId).get();
if (encoder.matches(nowPassword, loginUser.getPassword())) {
List<Like> likes = likeRepository.findAllByUserLoginId(loginId);
for (Like like : likes) {
like.getBoard().likeChange( like.getBoard().getLikeCnt() - 1 );
}
List<Comment> comments = commentRepository.findAllByUserLoginId(loginId);
for (Comment comment : comments) {
comment.getBoard().commentChange( comment.getBoard().getCommentCnt() - 1 );
}
userRepository.delete(loginUser);
return true;
} else {
return false;
}
}
public Page<User> findAllByNickname(String keyword, PageRequest pageRequest) {
return userRepository.findAllByNicknameContains(keyword, pageRequest);
}
@Transactional
public void changeRole(Long userId) {
User user = userRepository.findById(userId).get();
user.changeRole();
}
public UserCntDto getUserCnt() {
return UserCntDto.builder()
.totalUserCnt(userRepository.count())
.totalAdminCnt(userRepository.countAllByUserRole(UserRole.ADMIN))
.totalBronzeCnt(userRepository.countAllByUserRole(UserRole.BRONZE))
.totalSilverCnt(userRepository.countAllByUserRole(UserRole.SILVER))
.totalGoldCnt(userRepository.countAllByUserRole(UserRole.GOLD))
.totalBlacklistCnt(userRepository.countAllByUserRole(UserRole.BLACKLIST))
.build();
}
}
UserController
- 로그인 페이지 접속 시 이전 페이지를 세션에 저장함으로써, 로그인에 성공하면 이전 페이지로 이동시켜 줌
- /users/myPage/{category}는 마이 페이지에서 내가 작성한 글, 내가 댓글을 단 글, 내가 좋아요 누른 글을 볼 수 있는데, 이 때 {category}로 분류하여 리스트를 return
- /users/admin은 관리자 페이지 => 모든 유저의 정보를 확인 할 수 있음
- /users/admin/{userId}는 관리자가 userId에 해당하는 유저의 등급을 변경할 때 사용
- "printMessage"는 message에 해당 하는 값을 출력하고, nextUrl에 해당 하는 URL로 이동시켜 주는 HTML 파일
@Controller
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;
private final BoardService boardService;
@GetMapping("/join")
public String joinPage(Model model) {
model.addAttribute("userJoinRequest", new UserJoinRequest());
return "users/join";
}
@PostMapping("/join")
public String join(@Valid @ModelAttribute UserJoinRequest req, BindingResult bindingResult, Model model) {
// Validation
if (userService.joinValid(req, bindingResult).hasErrors()) {
return "users/join";
}
userService.join(req);
model.addAttribute("message", "회원가입에 성공했습니다!\n로그인 후 사용 가능합니다!");
model.addAttribute("nextUrl", "/users/login");
return "printMessage";
}
@GetMapping("/login")
public String loginPage(Model model, HttpServletRequest request) {
// 로그인 성공 시 이전 페이지로 redirect 되게 하기 위해 세션에 저장
String uri = request.getHeader("Referer");
if (uri != null && !uri.contains("/login") && !uri.contains("/join")) {
request.getSession().setAttribute("prevPage", uri);
}
model.addAttribute("userLoginRequest", new UserLoginRequest());
return "users/login";
}
@GetMapping("/myPage/{category}")
public String myPage(@PathVariable String category, Authentication auth, Model model) {
model.addAttribute("boards", boardService.findMyBoard(category, auth.getName()));
model.addAttribute("category", category);
model.addAttribute("user", userService.myInfo(auth.getName()));
return "users/myPage";
}
@GetMapping("/edit")
public String userEditPage(Authentication auth, Model model) {
User user = userService.myInfo(auth.getName());
model.addAttribute("userDto", UserDto.of(user));
return "users/edit";
}
@PostMapping("/edit")
public String userEdit(@Valid @ModelAttribute UserDto dto, BindingResult bindingResult,
Authentication auth, Model model) {
// Validation
if (userService.editValid(dto, bindingResult, auth.getName()).hasErrors()) {
return "users/edit";
}
userService.edit(dto, auth.getName());
model.addAttribute("message", "정보가 수정되었습니다.");
model.addAttribute("nextUrl", "/users/myPage/board");
return "printMessage";
}
@GetMapping("/delete")
public String userDeletePage(Authentication auth, Model model) {
User user = userService.myInfo(auth.getName());
model.addAttribute("userDto", UserDto.of(user));
return "users/delete";
}
@PostMapping("/delete")
public String userDelete(@ModelAttribute UserDto dto, Authentication auth, Model model) {
Boolean deleteSuccess = userService.delete(auth.getName(), dto.getNowPassword());
if (deleteSuccess) {
model.addAttribute("message", "탈퇴 되었습니다.");
model.addAttribute("nextUrl", "/users/logout");
return "printMessage";
} else {
model.addAttribute("message", "현재 비밀번호가 틀려 탈퇴에 실패하였습니다.");
model.addAttribute("nextUrl", "/users/delete");
return "printMessage";
}
}
@GetMapping("/admin")
public String adminPage(@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "") String keyword,
Model model) {
PageRequest pageRequest = PageRequest.of(page - 1, 10, Sort.by("id").descending());
Page<User> users = userService.findAllByNickname(keyword, pageRequest);
model.addAttribute("users", users);
model.addAttribute("keyword", keyword);
return "users/admin";
}
@GetMapping("/admin/{userId}")
public String adminChangeRole(@PathVariable Long userId,
@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "") String keyword) throws UnsupportedEncodingException {
userService.changeRole(userId);
return "redirect:/users/admin?page=" + page + "&keyword=" + URLEncoder.encode(keyword, "UTF-8");
}
}
User 관련 DTO
UserJoinRequest
- 회원가입 시 사용되는 DTO
@Data
public class UserJoinRequest {
private String loginId;
private String password;
private String passwordCheck;
private String nickname;
public User toEntity(String encodedPassword) {
return User.builder()
.loginId(loginId)
.password(encodedPassword)
.nickname(nickname)
.userRole(UserRole.BRONZE)
.createdAt(LocalDateTime.now())
.receivedLikeCnt(0)
.build();
}
}
UserLoginRequest
- 로그인 시 사용되는 DTO
@Data
public class UserLoginRequest {
private String loginId;
private String password;
}
UserDto
- 정보 수정, 탈퇴 등에 사용되는 DTO
@Data
@Builder
public class UserDto {
private String loginId;
private String nickname;
private String nowPassword;
private String newPassword;
private String newPasswordCheck;
public static UserDto of(User user) {
return UserDto.builder()
.loginId(user.getLoginId())
.nickname(user.getNickname())
.build();
}
}
UserCntDto
- 홈 화면에서 각각의 등급에 해당하는 User 수를 출력하기 위해 사용되는 DTO
@Data
@Builder
public class UserDto {
private String loginId;
private String nickname;
private String nowPassword;
private String newPassword;
private String newPasswordCheck;
public static UserDto of(User user) {
return UserDto.builder()
.loginId(user.getLoginId())
.nickname(user.getNickname())
.build();
}
}
Spring Security
- 로그인, 로그아웃, 인증, 인가 등은 Spring Security 사용 => UserDetail, UserDetailService는 [Spring Boot] Spring Security를 사용한 로그인 구현 (Form Login) 참고
BCryptPasswordEncoder
- 비밀번호 암호화 및 암호화 된 비밀번호와 일치하는지 확인하는데 사용
@Configuration
@EnableWebSecurity
public class EncrypterConfig {
@Bean
public BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
}
Security Config
- antMatchers().anonymous() => 로그인하지 않은 유저들만 접근 가능
- antMatchers().authenticated() => 로그인 한 유저들만 접근 가능
- antMatchers().hasAnyAuthority() => 설정한 등급의 유저들만 접근 가능
- 인증에 실패한 경우 MyAuthenticationEntryPoint, 인가에 실패한 경우 MyAccessDeniedHandler, 로그인에 성공한 경우 MyLoginSuccessHandler, 로그아웃에 성공한 경우 MyLogoutSuccessHandler 호출
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserRepository userRepository;
// 로그인하지 않은 유저들만 접근 가능한 URL
private static final String[] anonymousUserUrl = {"/users/login", "/users/join"};
// 로그인한 유저들만 접근 가능한 URL
private static final String[] authenticatedUserUrl = {"/boards/**/**/edit", "/boards/**/**/delete", "/likes/**", "/users/myPage/**", "/users/edit", "/users/delete"};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers(anonymousUserUrl).anonymous()
.antMatchers(authenticatedUserUrl).authenticated()
.antMatchers("/boards/greeting/write").hasAnyAuthority("BRONZE", "ADMIN")
.antMatchers(HttpMethod.POST, "/boards/greeting").hasAnyAuthority("BRONZE", "ADMIN")
.antMatchers("/boards/free/write").hasAnyAuthority("SILVER", "GOLD", "ADMIN")
.antMatchers(HttpMethod.POST, "/boards/free").hasAnyAuthority("SILVER", "GOLD", "ADMIN")
.antMatchers("/boards/gold/**").hasAnyAuthority("GOLD", "ADMIN")
.antMatchers("/users/admin/**").hasAuthority("ADMIN")
.antMatchers("/comments/**").hasAnyAuthority("BRONZE", "SILVER", "GOLD", "ADMIN")
.anyRequest().permitAll()
.and()
.exceptionHandling()
.accessDeniedHandler(new MyAccessDeniedHandler(userRepository)) // 인가 실패
.authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 인증 실패
.and()
// 폼 로그인
.formLogin()
.loginPage("/users/login") // 로그인 페이지
.usernameParameter("loginId") // 로그인에 사용될 id
.passwordParameter("password") // 로그인에 사용될 password
.failureUrl("/users/login?fail") // 로그인 실패 시 redirect 될 URL => 실패 메세지 출력
.successHandler(new MyLoginSuccessHandler(userRepository)) // 로그인 성공 시 실행 될 Handler
.and()
// 로그아웃
.logout()
.logoutUrl("/users/logout") // 로그아웃 URL
.invalidateHttpSession(true).deleteCookies("JSESSIONID")
.logoutSuccessHandler(new MyLogoutSuccessHandler())
.and()
.build();
}
}
인증 실패
- 인증에 실패한 경우 (로그인하지 않은 유저가 로그인이 필요한 URL에 접근한 경우) MyAuthenticationEntryPoint 호출
- HttpServletResponse, PrintWriter을 사용하여 메세지 출력 후 지정한 URL로 이동 시켜줌
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('로그인한 유저만 가능합니다!'); location.href='/users/login';</script>");
pw.flush();
}
}
인가 실패
- 인가에 실패한 경우 (일반 유저가 관리자 권한이 필요한 URL에 접근한 경우 등) MyAccessDeniedHandler 호출
- 인가에 실패한 URL을 통해 어떤 상황인지 파악 후 알맞은 메세지와 다음 페이지를 지정
@AllArgsConstructor
public class MyAccessDeniedHandler implements AccessDeniedHandler {
private final UserRepository userRepository;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User loginUser = null;
if (auth != null) {
loginUser = userRepository.findByLoginId(auth.getName()).get();
}
String requestURI = request.getRequestURI();
// 로그인한 유저가 login, join을 시도한 경우
if (requestURI.contains("/users/login") || requestURI.contains("/users/join")) {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('이미 로그인 되어있습니다!'); location.href='/';</script>");
pw.flush();
}
// 골드게시판은 GOLD, ADMIN만 접근 가능
else if (requestURI.contains("gold")) {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('골드 등급 이상의 유저만 접근 가능합니다!'); location.href='/';</script>");
pw.flush();
} else if (loginUser != null && loginUser.getUserRole().equals(UserRole.BLACKLIST)){
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('블랙리스트는 글, 댓글 작성이 불가능합니다.'); location.href='/';</script>");
pw.flush();
}
// BRONZE 등급이 자유게시판에 글을 작성하려는 경우
else if (requestURI.contains("free/write")) {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('가입인사 작성 후 작성 가능합니다!'); location.href='/boards/greeting';</script>");
pw.flush();
}
// SILVER 등급 이상이 가입인사를 작성하려는 경우
else if (requestURI.contains("greeting")) {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('가입인사는 한 번만 작성 가능합니다!'); location.href='/boards/greeting';</script>");
pw.flush();
}
// ADMIN이 아닌데 관리자 페이지에 접속한 경우
else if (requestURI.contains("admin")) {
// 메세지 출력 후 홈으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
pw.println("<script>alert('관리자만 접속 가능합니다!'); location.href='/';</script>");
pw.flush();
}
}
}
로그인 성공
- 로그인에 성공한 경우 MyLoginSuccessHandler 호출
- 유저에 맞는 메세지 출력 후 세션의 "prevPage"에 담긴 URL로 이동 시켜줌
- prevPage에는 로그인 하기 전 URL이 저장되어 있음
- 로그인 페이지 접속 시 해당 값 설정
@AllArgsConstructor
public class MyLoginSuccessHandler implements AuthenticationSuccessHandler {
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 세션 유지 시간 = 3600초
HttpSession session = request.getSession();
session.setMaxInactiveInterval(3600);
User loginUser = userRepository.findByLoginId(authentication.getName()).get();
// 성공 시 메세지 출력 후 홈 화면으로 redirect
response.setContentType("text/html");
PrintWriter pw = response.getWriter();
if (loginUser.getUserRole().equals(UserRole.BLACKLIST)) {
pw.println("<script>alert('" + loginUser.getNickname() + "님은 블랙리스트 입니다. 글, 댓글 작성이 불가능합니다.'); location.href='/';</script>");
} else {
String prevPage = (String) request.getSession().getAttribute("prevPage");
if (prevPage != null) {
pw.println("<script>alert('" + loginUser.getNickname() + "님 반갑습니다!'); location.href='" + prevPage + "';</script>");
} else {
pw.println("<script>alert('" + loginUser.getNickname() + "님 반갑습니다!'); location.href='/';</script>");
}
}
pw.flush();
}
}
로그아웃 성공
- 로그아웃에 성공한 경우 MyLogoutSuccessHandler 호출
- 메세지 출력후 홈으로 이동
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("text/html");
PrintWriter out = response.getWriter();
out.println("<script>alert('로그아웃 되었습니다.'); location.href='/';</script>");
out.flush();
}
}
반응형
'Spring Boot > 프로젝트' 카테고리의 다른 글
[Spring Boot] 게시판 만들기 5 - 댓글, 좋아요, 파일 업로드 관련 기능 (0) | 2023.04.17 |
---|---|
[Spring Boot] 게시판 만들기 4 - 게시판 기능 (0) | 2023.04.17 |
[Spring Boot] 게시판 만들기 2 - 라이브러리 설치, ERD, Entity 생성 (1) | 2023.04.17 |
[Spring Boot] 게시판 만들기 1 - 설계 & 결과 (2) | 2023.04.16 |
[Spring Boot] CRUD 게시판 (DB 사용 X, 회원 기능 X) (2) | 2022.05.11 |