feat: 관리자 로그인과 세션 보안 설정 추가
@9987a50b384f37d0d97fdd734f7daf57067de1a4
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties; | |
| 4 | + | |
| 5 | +@ConfigurationProperties(prefix = "app.admin") | |
| 6 | +public record AdminProperties(String username, String password) { | |
| 7 | +} |
+++ src/main/java/kr/itn/itnhub/config/SecurityConfig.java
... | ... | @@ -0,0 +1,63 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import jakarta.servlet.http.HttpServletResponse; | |
| 4 | +import org.springframework.context.annotation.Bean; | |
| 5 | +import org.springframework.context.annotation.Configuration; | |
| 6 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
| 7 | +import org.springframework.security.core.userdetails.User; | |
| 8 | +import org.springframework.security.core.userdetails.UserDetailsService; | |
| 9 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 10 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 11 | +import org.springframework.security.provisioning.InMemoryUserDetailsManager; | |
| 12 | +import org.springframework.security.web.SecurityFilterChain; | |
| 13 | +import org.springframework.security.web.authentication.HttpStatusEntryPoint; | |
| 14 | +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; | |
| 15 | +import org.springframework.http.HttpStatus; | |
| 16 | + | |
| 17 | +@Configuration | |
| 18 | +public class SecurityConfig { | |
| 19 | + | |
| 20 | + @Bean | |
| 21 | + PasswordEncoder passwordEncoder() { | |
| 22 | + return new BCryptPasswordEncoder(); | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** | |
| 26 | + * 관리자 1계정. 값은 환경변수로만 들어온다. | |
| 27 | + * 운영에서는 APP_ADMIN_PASSWORD에 충분히 긴 무작위 문자열을 넣는다. | |
| 28 | + */ | |
| 29 | + @Bean | |
| 30 | + UserDetailsService userDetailsService(AdminProperties admin, PasswordEncoder encoder) { | |
| 31 | + return new InMemoryUserDetailsManager( | |
| 32 | + User.withUsername(admin.username()) | |
| 33 | + .password(encoder.encode(admin.password())) | |
| 34 | + .roles("ADMIN") | |
| 35 | + .build()); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Bean | |
| 39 | + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | |
| 40 | + http | |
| 41 | + // SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다 | |
| 42 | + .csrf(csrf -> csrf.csrfTokenRepository( | |
| 43 | + CookieCsrfTokenRepository.withHttpOnlyFalse())) | |
| 44 | + .authorizeHttpRequests(auth -> auth | |
| 45 | + .requestMatchers("/api/auth/login").permitAll() | |
| 46 | + .requestMatchers("/api/**").authenticated() | |
| 47 | + .anyRequest().permitAll()) | |
| 48 | + .formLogin(form -> form | |
| 49 | + .loginProcessingUrl("/api/auth/login") | |
| 50 | + .successHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_OK)) | |
| 51 | + .failureHandler((req, res, e) -> | |
| 52 | + res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))) | |
| 53 | + .logout(logout -> logout | |
| 54 | + .logoutUrl("/api/auth/logout") | |
| 55 | + .logoutSuccessHandler((req, res, a) -> | |
| 56 | + res.setStatus(HttpServletResponse.SC_NO_CONTENT))) | |
| 57 | + // API는 로그인 페이지로 리다이렉트하지 않고 401을 준다 | |
| 58 | + .exceptionHandling(ex -> ex.authenticationEntryPoint( | |
| 59 | + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); | |
| 60 | + | |
| 61 | + return http.build(); | |
| 62 | + } | |
| 63 | +} |
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -33,3 +33,8 @@ |
| 33 | 33 |
base-url: ${MATTERMOST_URL}
|
| 34 | 34 |
token: ${MATTERMOST_TOKEN}
|
| 35 | 35 |
team-id: ${MATTERMOST_TEAM_ID}
|
| 36 |
+ |
|
| 37 |
+app: |
|
| 38 |
+ admin: |
|
| 39 |
+ username: ${APP_ADMIN_USERNAME}
|
|
| 40 |
+ password: ${APP_ADMIN_PASSWORD}
|
+++ src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
... | ... | @@ -0,0 +1,52 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 6 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 7 | +import org.springframework.test.web.servlet.MockMvc; | |
| 8 | + | |
| 9 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 10 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 11 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 12 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 13 | + | |
| 14 | +@AutoConfigureMockMvc | |
| 15 | +class SecurityConfigTest extends AbstractDbTest { | |
| 16 | + | |
| 17 | + @Autowired | |
| 18 | + MockMvc mvc; | |
| 19 | + | |
| 20 | + @Test | |
| 21 | + void 인증없이_api를_부르면_401이다() throws Exception { | |
| 22 | + mvc.perform(get("/api/orgs")) | |
| 23 | + .andExpect(status().isUnauthorized()); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Test | |
| 27 | + void 올바른_비밀번호로_로그인하면_200이다() throws Exception { | |
| 28 | + mvc.perform(post("/api/auth/login") | |
| 29 | + .param("username", "admin") | |
| 30 | + .param("password", "test-password") | |
| 31 | + .with(csrf())) | |
| 32 | + .andExpect(status().isOk()); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + void 틀린_비밀번호로_로그인하면_401이다() throws Exception { | |
| 37 | + mvc.perform(post("/api/auth/login") | |
| 38 | + .param("username", "admin") | |
| 39 | + .param("password", "wrong") | |
| 40 | + .with(csrf())) | |
| 41 | + .andExpect(status().isUnauthorized()); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception { | |
| 46 | + mvc.perform(post("/api/auth/login") | |
| 47 | + .param("username", "nobody") | |
| 48 | + .param("password", "nothing") | |
| 49 | + .with(csrf())) | |
| 50 | + .andExpect(status().isUnauthorized()); // 403이 아니라 401이어야 한다 | |
| 51 | + } | |
| 52 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?