스프링 숙련 주차
Updated:
Bean
Bean을 수동으로 등록하는 방법
@Component를 사용하면 @ComponentScan에 의해서 해당 클래스를 Bean으로 자동 등록해준다. 비즈니스 로직과 관련된 클래스들은 그 수가 많기 때문에 @Controller, @Service와 같은 애너테이션들을 사용해서 Bean으로 등록하고 관리하면 개발 생산성에 유리하다. 하지만 기술적인 문제나 공통적인 관심사를 처리할 때 사용하는 객체들은 수동으로 등록하는 것이 좋다.
@Component
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- Bean으로 등록하고자 하는 객체를 반환하는 메서드를 선언하고 @Bean을 설정
- Bean을 등록하는 메서드가 속한 해당 클래스에 @Configuration을 설정
위 메서드를 테스트 해본다.
@SpringBootTest
class PasswordEncoderTest {
@Autowired
PasswordEncoder passwordEncoder;
@Test
@DisplayName("수동 등록한 passwordEncoder를 주입 받아와 문자열 암호화")
void test1() {
String password = "password";
// 암호화
String encodedPassword = passwordEncoder.encode(password);
System.out.println("encodedPassword = " + encodedPassword);
String inputPassword = "password123";
// 복화하를 통해 암호화된 비밀번호화 비교
boolean matches = passwordEncoder.matches(inputPassword, encodedPassword);
System.out.println("matches = " + matches); // false
}
}
같은 타입의 Bean이 2개일 때
public interface Food {
void eat();
}
위 interface를 상속받는 두 class를 작성한다.
@Component
public class Chicken implements Food {
@Override
public void eat() {
System.out.println("치킨을 먹습니다.");
}
}
@Component
public class Pizza implements Food {
@Override
public void eat() {
System.out.println("피자를 먹습니다.");
}
}
테스트 코드를 작성해본다.
@SpringBootTest
public class BaenTest {
@Autowired
Food food;
}
위 코드를 보면 Could not autowire. There is more than one bean of 'Food' type.Beans:chicken (Chicken.java)pizza (Pizza.java) 과 같은 error가 발생한다. 아는 fodd 필드에 Bean을 주입해줘야 하는데 같은 타입의 Bean 객체가 하나 이상 있기 때문에 어떤 Bean을 등록해줘야할지 몰라 오류가 발생한 것이다. 이는 다음과 같이 해결할 수 있다.
- 등록된 Bean 이름 명시
@SpringBootTest public class BaenTest { @Autowired Food pizza; @Autowired Food chicken; } - @Primary 사용하기
@Component @Primary public class Chicken implements Food { @Override public void eat() { System.out.println("치킨을 먹습니다."); } }- @Primary가 추가되면 같은 타입의 Bean이 여러 개 있더라도 우선 주입 해준다.
- @Qualifier 사용하기
@Component @Qualifier("pizza") public class Pizza implements Food { @Override public void eat() { System.out.println("피자를 먹습니다."); } }@SpringBootTest public class BaenTest { @Autowired @Qualifier("pizza") Food food; }
같은 타입의 Bean들에 Quailfer와 Primary가 동시에 적용되어있다면 Qualifier의 우선순위가 높다. 범용적으로 사용되는 Bean 객체는 Primary를 설정하고 지엽적으로 사용되는 Bean 객체에는 Qualifer를 사용하는 것이 좋다.
인증과 인가
- 인증(Authentication): 해당 유저가 실제 유저인지 인증하는 개념
- 인가(Authorication): 특정 리소스에 접근이 가능한지 허가를 확인하는 개념
웹 애플리케이션은 서버-클라이언트 구조로 되어있고 Http 프로토콜을 이용하여 비연결성(Connectionless) 무상태(Stateless)로 통신한다. 일반적으로 웹 애플리케이션은 두 가지 방법을 통해서 인증을 처리한다.
- 쿠기-세션 방식의 인증

- 서버가 특정 유저가 로그인 되었다는 상태를 저장하는 방식
- 인증과 관련된 최소한의 정보만 저장해서 로그인을 유지
- JWT 기반 인증

- JWT(JSON Web Token): 인증에 필요한 정보들을 암호화시킨 토큰
- JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별
쿠키와 세션
쿠키와 세션 모두 HTTP에 상태 정보를 유지(Stateful)하기 위해 사용된다. 즉, 쿠키와 세션을 통해 서버에서는 클라이언트 별로 인증 및 인가를 할 수 있게 된다.
- 쿠키: 클라리언트에 저장될 목적으로 생성한 작은 정보를 담은 파일

- Name: 쿠키를 구별하는 데 사용되는 키(중복 X)
- Value: 쿠키의 값
- Domain: 쿠키가 저장된 도메인
- Path: 쿠키가 사용되는 경로
- Expires: 쿠키의 만료기한
- 세션: 서버에서 일정 시간동안 클라이언트 상태를 유지하기 위해 사용
- 서버에서 클리이언트 별로 유일무이한 세션 ID를 부여한 후 클라리언트 별 필요한 정보를 서버에 저장
- 서버에서 생성한 세션 ID는 클라이언트의 쿠키값(세션 쿠키)으로 저장되어 클라이언트 식별에 사용
이제 쿠키를 Spring에서 다뤄보자.
- 쿠키 생성
public static void addCookie(String cookieValue, HttpServletResponse res) { try { cookieValue = URLEncoder.encode(cookieValue, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행 Cookie cookie = new Cookie(AUTHORIZATION_HEADER, cookieValue); // Name-Value cookie.setPath("/"); cookie.setMaxAge(30 * 60); // Response 객체에 Cookie 추가 res.addCookie(cookie); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage()); } }- new Cookie(AUTHORIZATION_HEADER, cookieValue): Cookie에 저장될 Name과 Value를 생성자로 받는 객체 생성
- setPath(“/”), setMaxAge(30*60): Path와 만료시간 지정
- HttpServletResponse 객체에 생성한 Cookie 객체를 추가하여 부라우저로 반환(Cookie는 브라우저 Cookie 저장소에 저장)
- Cookie 생성은 범용적으로 사용될 수 있기 때문에 static 메서드로 선언
- 쿠키 읽기
@GetMapping("/get-cookie") public String getCookie(@CookieValue(AUTHORIZATION_HEADER) String value) { System.out.println("value = " + value); return "getCookie : " + value; }- @CookieValue(“Cookie의 Name”): Cookie의 Name 정보를 전달해주면 해당 정보를 토대로 Cookie의 Value를 가져옴
이번에는 세션을 다뤄보자. Servelt에서는 유일무이한 세션 ID를 간편하게 만들수 있는 HttpSession을 제공해준다.
- HttpSession 생성
@GetMapping("/create-session") public String createSession(HttpServletRequest req) { // 세션이 존재할 경우 세션 반환, 없을 경우 새로운 세션을 생성한 후 반환 HttpSession session = req.getSession(true); // 세션에 저장될 정보 Name - Value 를 추가합니다. session.setAttribute(AUTHORIZATION_HEADER, "Robbie Auth"); return "createSession"; }- HttpServletRequest를 사용해서 세션을 생성 및 반환할 수 있음
- req.getSession(true): 세션이 존재할 경우 세션을 반환하고 없을 경우 새로운 세션을 생성
- 반환된 세션은 Cookie 저장소에 ‘JSESSIONID’라는 Name으로 Value에 저장됨
- HttpSession 읽기
@GetMapping("/get-session") public String getSession(HttpServletRequest req) { // 세션이 존재할 경우 세션 반환, 없을 경우 null 반환 HttpSession session = req.getSession(false); String value = (String) session.getAttribute(AUTHORIZATION_HEADER); // 가져온 세션에 저장된 Value 를 Name 을 사용하여 가져옵니다. System.out.println("value = " + value); return "getSession : " + value; }- req.getSession(false): 세션이 존재할 경우 세션을 반환하고 없을 경우 null 반환
- session.getAttribute(“세션에 저장된 정보 Name”): Name을 사용하여 세션에 저장된 Value를 가져옴
JWT
JWT는 JSON 포맷을 이용한 토큰의 한 종류로 쿠키 저장소를 사용하여 JWT를 저장한다.
- 장점
- 동시 접속자가 많을 때 서버 측 부하 낮춤
- Client, Server가 다른 도메인을 사용할 때
- 단점
- 구현의 복잡도 증가
- JWT에 담는 내용이 커질 수록 네트워크 비용 증가
- 기 생성된 JWT를 일부만 만료시킬 방법이 없음
- Secret key 유출 시 JWT 조작 가능
이제 이 JWT를 다뤄보자. 먼저 JWT dependency를 와 application.properties에 secret key를 추가한다.
// build.gradle
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
// application.properties
jwt.secret.key=...
JwtUtil 클래스를 먼저 생성한다. 여기서 Util 클래스는 특정 파라미터에 대한 작업을 수행하는 메서드들이 존재하는 클래스를 뜻한다. JWT와 관련된 기능으로는 JWT 생성, 생성된 JWT를 Cookie에 저장, Cookie에 들어있던 JWT 토큰을 Substring, JWT 검증, JWT에서 사용자 정보 보기가 있다. JWT는 ResponseHeader에 담아서 보낼 수도 있고 Cookie 객체에 토큰을 담아 Cookie를 Response 객체에 담는 방법이 있다. 먼저 Cookie를 직접 만들어 보내는 방법을 알아보자.
- 토큰 생성에 필요한 데이터
// Header KEY 값 (Cookie의 Name 값) public static final String AUTHORIZATION_HEADER = "Authorization"; // 사용자 권한 값의 KEY public static final String AUTHORIZATION_KEY = "auth"; // Token 식별자 public static final String BEARER_PREFIX = "Bearer "; // JWT 혹은 OAuth에 대한 토큰을 사용한다는 표시 // 토큰 만료시간 private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분 @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey private String secretKey; private Key key; private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; // 로그 설정 public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그"); @PostConstruct public void init() { byte[] bytes = Base64.getDecoder().decode(secretKey); key = Keys.hmacShaKeyFor(bytes); }- Base64로 Encode된 Secret Key를 properties에 작성하고 @Value를 통해 가져온다.
- JWT를 생성할 때 가져온 Secret Key로 암호화한다.
- 이때 Encode 된 Secret Key를 Decode 해서 사용
- key는 Decode 된 Secret Key를 담는 객체
- @PostConstruct는 딱 한 번만 받아오면 되는 값을 사용 할 때마다 요청을 새로 호출하는 실수를 방지하기 위해 사용
- 암호화 알고리즘은 HS256 사용
- 로깅: 애플리케이션이 동작하는 동안 프로젝트의 상태나 동작 정보를 시간순으로 기록하는 것
- 여기서는 Logback 로깅 프레임워크를 사용
- @slf4j를 사용하는 방법도 있음
- 사용자의 권한의 종류를 Enum을 사용해 관리
package com.sparta.springauth.entity; public enum UserRoleEnum { USER(Authority.USER), // 사용자 권한 ADMIN(Authority.ADMIN); // 관리자 권한 private final String authority; UserRoleEnum(String authority) { this.authority = authority; } public String getAuthority() { return this.authority; } public static class Authority { public static final String USER = "ROLE_USER"; public static final String ADMIN = "ROLE_ADMIN"; } }- JWT를 생성할 때 사용자의 정보로 해당 사용자의 권한을 넣어줄 때 사용
- JWT 생성
// 토큰 생성 public String createToken(String username, UserRoleEnum role) { Date date = new Date(); return BEARER_PREFIX + Jwts.builder() .setSubject(username) // 사용자 식별자값(ID) .claim(AUTHORIZATION_KEY, role) // 사용자 권한 .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 .setIssuedAt(date) // 발급일 .signWith(key, signatureAlgorithm) // 암호화 알고리즘 .compact(); }- 토큰을 builder를 통해 생성한다.
- 경우에 따라 필요한 값들만 넣어도 된다.
- JWT Cookie에 저장
// 생성된 JWT를 Cookie에 저장 public void addJwtToCookie(String token, HttpServletResponse res) { try { token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행 Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value cookie.setPath("/"); // Response 객체에 Cookie 추가 res.addCookie(cookie); } catch (UnsupportedEncodingException e) { logger.error(e.getMessage()); } } - 받아온 Cookie의 Value인 JWT 토큰 Substring
// Cookie에 들어있던 JWT 토큰을 Substring public String substringToken(String tokenValue) { if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) { return tokenValue.substring(7); } logger.error("Not Found Token"); throw new NullPointerException("Not Found Token"); }- StringUtils.hasText를 사용하여 공백, null을 확인하고 startWith을 사용하여 토큰의 시작값이 Bearer이 맞는지 확인하고 맞다면 순수 JWT를 반환하기 위해 substring을 사용하여 Bearer을 잘라낸다.
- JWT 검증
// JWT 검증 public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; } catch (SecurityException | MalformedJwtException | SignatureException e) { logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다."); } catch (ExpiredJwtException e) { logger.error("Expired JWT token, 만료된 JWT token 입니다."); } catch (UnsupportedJwtException e) { logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다."); } catch (IllegalArgumentException e) { logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다."); } return false; }- Jwts.parerBuilder()를 사용하여 JWT를 파싱할 수 있음
- JWT가 위변조되지 않았는지 secretKey(key)값을 넣어 확인
- JWT에서 사용자 정보 가져오기
// JWT에서 사용자 정보 가져오기 public Claims getUserInfoFromToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); }- JWT의 구조 중 Payload 부분에는 토큰이 담긴 정보가 들어 있고 여기에 담긴 정보의 한 조각을 클레임(claim)이라 부르고, 이는 key-value 의 한 쌍으로 이뤄져있음
- 토큰에는 여러 개의 클레임들을 넣을 수 있음
- Jwts.parserBuilder()와 secretKey를 사용하여 JWT의 Claims를 가져와 담겨 있는 사용자 정보를 사용
이제 JWT를 테스트 해보기위해 AuthController에 다음을 추가하고 JwtUtil Bean을 가져오기 위해 @RequiredArgsConstructor와 private final JwtUtil jwtUtil;을 추가한다.
@GetMapping("/create-jwt")
public String createJwt(HttpServletResponse res) {
// Jwt 생성
String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);
// Jwt 쿠키 저장
jwtUtil.addJwtToCookie(token, res);
return "createJwt : " + token;
}
@GetMapping("/get-jwt")
public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if(!jwtUtil.validateToken(token)){
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);
return "getJwt : " + username + ", " + authority;
}
사용자 관리하기
회원가입, 로그인 구현
회원가입을 구현하기 전에 build.gradle에 JPA와 MySQL을 추가하고 Database를 연동한다. 페이지 Controller와 html 파일들을 넣어준다.
- HomeController
@Controller public class HomeController { @GetMapping("/") public String home(Model model) { model.addAttribute("username", "username"); return "index"; } }@Controller @RequestMapping("/api") public class UserController { @GetMapping("/user/login-page") public String loginPage() { return "login"; } @GetMapping("/user/signup") public String signupPage() { return "signup"; } }

사용자의 정보를 넣을 User Entity를 작성한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
}
- @Enumerated(value = EnumType.String) : EnumType을 DB 컬럼에 저장할 때 사용하는 애너테이션
회원 등록 시 비밀번호는 사용자가 입력한 문자 그대로 DB에 등록하면 안되고 암호화(Encryption)가 의무이다. 이때 암호화는 복호화가 불가능한 단방향 암호 알고리즘을 사용해야 한다.
이제 회원가입 API를 구현해 본다.
- UserController
@PostMapping("/user/login") public String login(LoginRequestDto requestDto, HttpServletResponse res) { try { userService.login(requestDto, res); } catch (Exception e) { return "redirect:/api/user/login-page?error"; } return "redirect:/"; } - SignupRequestDto
@Getter @Setter public class SignupRequestDto { private String username; private String password; private String email; private boolean admin = false; private String adminToken = ""; } - UserService
@Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } // ADMIN_TOKEN private final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC"; public void signup(SignupRequestDto requestDto) { String username = requestDto.getUsername(); String password = passwordEncoder.encode(requestDto.getPassword()); // 회원 중복 확인 Optional<User> checkUsername = userRepository.findByUsername(username); if (checkUsername.isPresent()) { throw new IllegalArgumentException("중복된 사용자가 존재합니다."); } // email 중복확인 String email = requestDto.getEmail(); Optional<User> checkEmail = userRepository.findByEmail(email); if (checkEmail.isPresent()) { throw new IllegalArgumentException("중복된 Email 입니다."); } // 사용자 ROLE 확인 UserRoleEnum role = UserRoleEnum.USER; if (requestDto.isAdmin()) { if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) { throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다."); } role = UserRoleEnum.ADMIN; } // 사용자 등록 User user = new User(username, password, email, role); userRepository.save(user); } }- ADMIN_TOKEN: 관리자 토큰(현업에서는 이렇게 간단하게 하지 않음)
이번엔 JWT를 이용해 로그인을 구현해본다.
@Setter
@Getter
public class LoginRequestDto {
private String username;
private String password;
}
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
// ...
public void login(LoginRequestDto requestDto, HttpServletResponse res) {
String username = requestDto.getUsername();
String password = requestDto.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new IllegalArgumentException("등록된 사용자가 없습니다.")
);
// 비밀번호 확인
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
// JWT 생성 및 쿠키에 저장 후 Response 객체에 추가
String token = jwtUtil.createToken(user.getUsername(), user.getRole());
jwtUtil.addJwtToCookie(token, res);
}
}
필터

Filter는 Web 애플리케이션에서 관리되는 영역으로 Client로 부터 오는 요청과 응답에 대해 최초, 최종 단계의 위치이며 이를 통해 요청과 응답의 정보를 변경하거나 부가적인 기능을 추가할 수 있다. 주로 범용적으로 처리해야 하는 작업들, 예를들어 로깅 및 보안 처리에 활용된다. 또한 인증, 인가와 관려된 로직들을 처리할 수도 있다. 또한 Filter는 한 개만 존재하는 것이 아니라 여러 개가 Chain 형식으로 묶여서 처리될 수 있다.
요청 URL의 인가 처리 및 인증 처리를 진행할 수 있는 Filter와 요청 URL을 로깅해주는 Filter를 구현해보자.
- LoggingFilter: Request URL Logging
@Slf4j(topic = "LoggingFilter") @Component @Order(1) public class LoggingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 전처리 HttpServletRequest httpServletRequest = (HttpServletRequest) request; String url = httpServletRequest.getRequestURI(); log.info(url); chain.doFilter(request, response); // 다음 Filter 로 이동 // 후처리 log.info("비즈니스 로직 완료"); } }- @Order(1): 필터의 순서 지정
- chain.doFilter(request, response): 다음 Filter로 이동
- AuthFilter: 인증 및 인가 처리 필터
@Slf4j(topic = "AuthFilter") @Component @Order(2) public class AuthFilter implements Filter { private final UserRepository userRepository; private final JwtUtil jwtUtil; public AuthFilter(UserRepository userRepository, JwtUtil jwtUtil) { this.userRepository = userRepository; this.jwtUtil = jwtUtil; } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String url = httpServletRequest.getRequestURI(); if (StringUtils.hasText(url) && (url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js")) ) { // 회원가입, 로그인 관련 API 는 인증 필요없이 요청 진행 chain.doFilter(request, response); // 다음 Filter 로 이동 } else { // 나머지 API 요청은 인증 처리 진행 // 토큰 확인 String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest); if (StringUtils.hasText(tokenValue)) { // 토큰이 존재하면 검증 시작 // JWT 토큰 substring String token = jwtUtil.substringToken(tokenValue); // 토큰 검증 if (!jwtUtil.validateToken(token)) { throw new IllegalArgumentException("Token Error"); } // 토큰에서 사용자 정보 가져오기 Claims info = jwtUtil.getUserInfoFromToken(token); User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() -> new NullPointerException("Not Found User") ); request.setAttribute("user", user); chain.doFilter(request, response); // 다음 Filter 로 이동 } else { throw new IllegalArgumentException("Not Found Token"); } } } }- httpServletRequest.getRequestURI(): 요청 URL을 가져와서 구분(인가)
- “/api/user”, “/css”, “/js”로 시작하는 URL은 인증 처리에서 제외
- jwtUtil.getTokenFromRequest(httpServletRequest): httpServletRequest에서 Cookie 목록을 가져와 JWT가 저장된 Cookie를 찾음
- getTokenFromRequest 메서드를 JwtUtil에 구현
public String getTokenFromRequest(HttpServletRequest req) { Cookie[] cookies = req.getCookies(); if(cookies != null) { for (Cookie cookie : cookies) { if (cookie.getName().equals(AUTHORIZATION_HEADER)) { try { return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode } catch (UnsupportedEncodingException e) { return null; } } } } return null; }
- getTokenFromRequest 메서드를 JwtUtil에 구현
- httpServletRequest.getRequestURI(): 요청 URL을 가져와서 구분(인가)
- ProductController
@Controller @RequestMapping("/api") public class ProductController { @GetMapping("/products") public String getProducts(HttpServletRequest req) { System.out.println("ProductController.getProducts : 인증 완료"); User user = (User) req.getAttribute("user"); System.out.println("user.getUsername() = " + user.getUsername()); return "redirect:/"; } }- Filter에서 인증 처리되어 넘어온 User 객체를 사용하면 API 요청을 한 해당 사용자가 등록한 제품만 조회할 수 있음
Spring Security 프레임워크
Spring Security 프레임워크는 Spring 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공해준다. 이를 사용해 보기 위해 Spring Security Dependency를 추가하고 전에 했던 (exclude = SecurityAutoConfiguration.class) 을 삭제한다.
Spring Security를 설정해본다. 또한 전에 만들었던 LogginFilter와 AuthFilter의 @Component를 주석처리한다.
@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf((csrf) -> csrf.disable());
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
.requestMatchers("/api//user/**").permitAll()
.anyRequest().authenticated() // 그 외 모든 요청 인증처리
);
// 로그인 사용
http.formLogin(Customizer.withDefaults());
return http.build();
}
}
- CSRF(Corss-Site Request Forgery): 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않는 요청을 전달하는것
- 쿠기 기반의 취약점을 이용한 공격이기 때문에 RESTAPI 에서는 disable
- http.formLogin(Customizer.withDefaults()): Spring Security의 default 로그인 기능
- 로그인 페이지와 username(
user)과 password(Spring 로그 확인) 제공
- 로그인 페이지와 username(
Spring에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller로 분배된다. 이때 각 요청에 대해서 공통적으로 처리해야할 필요가 있을 때 DispatcherServlet 이전에 단계가 필요하며 이것이 Filter이다.

- Spring Security는 FilterChainProxy를 통해서 상세로직을 구현하고 있음

- Form Login 기반 인증은 인증이 필요한 URL 요청이 들어왔을 때 인증이 되지 않았다면 로그인 페이지를 반환하는 형태

- UsernamePasswordAuthenticationFilter는 Spring Security의 필터인 AbstractAuthenticationProcessingFilter를 상속한 Filter
- 기본적으로 Form Login 기반을 사용할 때 username과 password를 확인하여 인증
- 인증 과정
- 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 인증된 사용자의 정보가 담기는 인증 객체인 Authentication의 종류 중 하나인 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager에게 넘겨 인증을 시도
- 실패하면 SecurityContextHolder를 비움
- 성공하면 SecurityCOntextHolder에 Authentication를 세팅

- SecurityContext는 인증이 완료된 사용자의 상세 정보(Authentication)를 저장
- SecurityContext는 SecurityContextHolder로 접근할 수 있음

- 현재 인증된 사용자를 나타내며 SecurityContext에서 가져올 수 있음
- principal: 사용자 식별
- Username / Password 방식으로 인증할 때 일반적으로 UserDetails 인스턴스
- credentials: 주로 비밀번호, 대부분 사용자 인증에서 사용한 후 비움
- authorities: 사용자에게 부여한 권한을 GrantedAuthority로 추상화하여 사용
다음은 Spring Security를 사용하는 로그인 처리 과정이다.


- Client의 요청은 모두 Spring Security를 거치게 됨
- Spring Security의 역할: 인증, 인가
- 성공 시: Controller로 Client 요청 전달
- 실패 시: Controller로 Client 요청 전달되지 않음(Error Response)
위 과정을 구현해본다.
- WebSecurityConfig
@Configuration @EnableWebSecurity // Spring Security 지원을 가능하게 함 public class WebSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // CSRF 설정 // ... // 로그인 사용 http.formLogin((formLogin) -> formLogin // 로그인 View 제공 (GET /api/user/login-page) .loginPage("/api/user/login-page") // 로그인 처리 (POST /api/user/login) .loginProcessingUrl("/api/user/login") // 로그인 처리 후 성공 시 URL .defaultSuccessUrl("/") // 로그인 처리 후 실패 시 URL .failureUrl("/api/user/login-page?error") .permitAll() ); return http.build(); } } - UserDetailsServiceImpl
@Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository userRepository; public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("Not Found " + username)); return new UserDetailsImpl(user); } } - UserDetailsImpl
public class UserDetailsImpl implements UserDetails { private final User user; public UserDetailsImpl(User user) { this.user = user; } public User getUser() { return user; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { UserRoleEnum role = user.getRole(); String authority = role.getAuthority(); SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority); Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(simpleGrantedAuthority); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
UserDetailsService와 UserDetails를 직접 구현해서 사용하게 되면 Security의 default 로그인 기능을 사용하지 않겠다는 설정이 되어 Security의 password를 더 이상 제공하지 않는 것을 확인할 수 있다.
ProductController로 수정한다.
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(@AuthenticationPrincipal UserDetailsImpl userDetails) {
User user = userDetails.getUser();
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
이번엔 Spring Security를 이용해 JWT 로그인을 구현해본다.
- JwtAuthenticationFilter: 로그인 진행 및 JWT 생성
- 이제 Filter에서 로그인을 진핼할 것이므로 UserController에 있는 login 메서드와 UserService에 있는 login 매서드를 삭제한다.
@Slf4j(topic = "로그인 및 JWT 생성") public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; setFilterProcessesUrl("/api/user/login"); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { log.info("로그인 시도"); try { LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class); return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( requestDto.getUsername(), requestDto.getPassword(), null ) ); } catch (IOException e) { log.error(e.getMessage()); throw new RuntimeException(e.getMessage()); } } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { log.info("로그인 성공 및 JWT 생성"); String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername(); UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole(); String token = jwtUtil.createToken(username, role); jwtUtil.addJwtToCookie(token, response); } @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { log.info("로그인 실패"); response.setStatus(401); } }
- 이제 Filter에서 로그인을 진핼할 것이므로 UserController에 있는 login 메서드와 UserService에 있는 login 매서드를 삭제한다.
- JwtAuthoricationFilter: API에 전달되는 JWT 유효성 검증 및 인가 처리
@Slf4j(topic = "JWT 검증 및 인가") public class JwtAuthorizationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserDetailsServiceImpl userDetailsService; public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) { this.jwtUtil = jwtUtil; this.userDetailsService = userDetailsService; } @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException { String tokenValue = jwtUtil.getTokenFromRequest(req); if (StringUtils.hasText(tokenValue)) { // JWT 토큰 substring tokenValue = jwtUtil.substringToken(tokenValue); log.info(tokenValue); if (!jwtUtil.validateToken(tokenValue)) { log.error("Token Error"); return; } Claims info = jwtUtil.getUserInfoFromToken(tokenValue); try { setAuthentication(info.getSubject()); } catch (Exception e) { log.error(e.getMessage()); return; } } filterChain.doFilter(req, res); } // 인증 처리 public void setAuthentication(String username) { SecurityContext context = SecurityContextHolder.createEmptyContext(); Authentication authentication = createAuthentication(username); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); } // 인증 객체 생성 private Authentication createAuthentication(String username) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } } - WebSecurityConfig도 위 Filter를 사용할 수 있도록 수정하자
@Configuration @EnableWebSecurity // Spring Security 지원을 가능하게 함 public class WebSecurityConfig { private final JwtUtil jwtUtil; private final UserDetailsServiceImpl userDetailsService; private final AuthenticationConfiguration authenticationConfiguration; public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) { this.jwtUtil = jwtUtil; this.userDetailsService = userDetailsService; this.authenticationConfiguration = authenticationConfiguration; } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception { JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil); filter.setAuthenticationManager(authenticationManager(authenticationConfiguration)); return filter; } @Bean public JwtAuthorizationFilter jwtAuthorizationFilter() { return new JwtAuthorizationFilter(jwtUtil, userDetailsService); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // CSRF 설정 http.csrf((csrf) -> csrf.disable()); // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정 http.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); http.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 .requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가 .anyRequest().authenticated() // 그 외 모든 요청 인증처리 ); http.formLogin((formLogin) -> formLogin .loginPage("/api/user/login-page").permitAll() ); // 필터 관리 >> 필터 순서 설정 http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class); http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }
접근 불가 페이지 만들기
회원 상세정보(UserDetailsImpl)를 통해 “권한(Authority)” 설정이 가능하다. 권한을 1개 이상 설정이 가능하고 “ROLE_“로 시작하는 권한 이름 규칙이 있다. 이는 role의 authority 값을 사용하여 동적으로 저장된다.
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Spring Security를 이용한 API 별 권한 제어 방법은 Controller에 @Secured 에너테이션으로 권한 설정이 가능하다. 이 애너테이션을 활성화 하기 위해서는 WebSecurityConfig 클래스에 @EnableGlobalMethodSecurity(securedEnabled = true) 를 추가해야한다.
@Secured(UserRoleEnum.Authority.ADMIN) // 관리자용, @Secured("권한 이름")
@GetMapping("/products/secured")
public String getProductsByAdmin(@AuthenticationPrincipal UserDetailsImpl userDetails) {
System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
for (GrantedAuthority authority : userDetails.getAuthorities()) {
System.out.println("authority.getAuthority() = " + authority.getAuthority());
}
return "redirect:/";
}
지금은 Admin이 아닌 사용자로 /api/products/secured에 접근하면 로그인 페이지로 튕겨나가 버린다. 그러므로 접근 불가 페이지를 적용해본다. 접근 불가 페이지 html 파일을 resources/static 에 작성한다. 이후 WebSecurityConfig에 접근 불가 페이지 URL을 설정한다.
// 접근 불가 페이지
http.exceptionHandling((exceptionHandling) ->
exceptionHandling
.accessDeniedPage("/fodbidden.html")
);
데이터 검증하기
Java는 null 값에 대한 접근에 대해 NullPointerException 요류가 발생하기 때문에 이러한 부분을 예방하기 위해 Validation 즉, 검증 과정이 필요하다. Spring에서는 null 확인 뿐 아니라 문자의 길이 측정과 같은 다른 검증 과정도 쉽게 처리할 수 있도록 Bean Validation을 제공하고 있다.
- @NotNull: null 불가
- @NotEmpty: null, “” 불가
- @NotBlank: null, “”, “ “ 불가
- @Size: 문자 길이 측정
- @Max: 최대값
- @Min: 최소값
- @Positive: 양수
- @Negative: 음수
- @Email: E-mail 형식
- @Pattern: 정규 표현식
먼저 validation dependency를 추가한다. 이후 코드를 추가한다.
@Getter
public class ProductRequestDto {
@NotBlank
private String name;
@Email
private String email;
@Positive(message = "양수만 가능합니다.")
private int price;
@Negative(message = "음수만 가능합니다.")
private int discount;
@Size(min=2, max=10)
private String link;
@Max(10)
private int max;
@Min(2)
private int min;
}
이를 테스트 해본다.
@PostMapping("/validation")
@ResponseBody
public ProductRequestDto testValid(@RequestBody @Valid ProductRequestDto requestDto) {
return requestDto;
}
- @Valid: Validation 기능 사용
Postman에서 이를 실행해보면 다음과 같이 JSON이 반환된다. 만약 여기서 위에서 설정한 Validation에 위반되면 오류가 발생한다.
{"name":"Robbie","email":"Robbie@gmail.com","price":1234,"discount":-1234,"link":"54321","max":10,"min":2}
이번에는 Validation 예외처리를 해본다. 기존 회원가입 코드를 수정해본다.
@Getter
@Setter
public class SignupRequestDto {
@NotBlank
private String username;
@NotBlank
private String password;
@Email
@NotBlank
private String email;
private boolean admin = false;
private String adminToken = "";
}
UserController에도 다음 코드를 추가한다.
@PostMapping("/user/signup")
public String signup(@Valid SignupRequestDto requestDto, BindingResult bindingResult) {
// Validation 예외처리
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
if(fieldErrors.size() > 0) {
for (FieldError fieldError : bindingResult.getFieldErrors()) {
log.error(fieldError.getField() + " 필드 : " + fieldError.getDefaultMessage());
}
return "redirect:/api/user/signup";
}
userService.signup(requestDto);
return "redirect:/api/user/login-page";
}
댓글남기기