ITN Dev 07-22
feat: 관리자 로그인과 세션 보안 설정 추가
@9987a50b384f37d0d97fdd734f7daf57067de1a4
 
src/main/java/kr/itn/itnhub/config/AdminProperties.java (added)
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
@@ -0,0 +1,7 @@
+package kr.itn.itnhub.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app.admin")
+public record AdminProperties(String username, String password) {
+}
 
src/main/java/kr/itn/itnhub/config/SecurityConfig.java (added)
+++ src/main/java/kr/itn/itnhub/config/SecurityConfig.java
@@ -0,0 +1,63 @@
+package kr.itn.itnhub.config;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.HttpStatusEntryPoint;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.http.HttpStatus;
+
+@Configuration
+public class SecurityConfig {
+
+    @Bean
+    PasswordEncoder passwordEncoder() {
+        return new BCryptPasswordEncoder();
+    }
+
+    /**
+     * 관리자 1계정. 값은 환경변수로만 들어온다.
+     * 운영에서는 APP_ADMIN_PASSWORD에 충분히 긴 무작위 문자열을 넣는다.
+     */
+    @Bean
+    UserDetailsService userDetailsService(AdminProperties admin, PasswordEncoder encoder) {
+        return new InMemoryUserDetailsManager(
+                User.withUsername(admin.username())
+                        .password(encoder.encode(admin.password()))
+                        .roles("ADMIN")
+                        .build());
+    }
+
+    @Bean
+    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+        http
+                // SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다
+                .csrf(csrf -> csrf.csrfTokenRepository(
+                        CookieCsrfTokenRepository.withHttpOnlyFalse()))
+                .authorizeHttpRequests(auth -> auth
+                        .requestMatchers("/api/auth/login").permitAll()
+                        .requestMatchers("/api/**").authenticated()
+                        .anyRequest().permitAll())
+                .formLogin(form -> form
+                        .loginProcessingUrl("/api/auth/login")
+                        .successHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_OK))
+                        .failureHandler((req, res, e) ->
+                                res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
+                .logout(logout -> logout
+                        .logoutUrl("/api/auth/logout")
+                        .logoutSuccessHandler((req, res, a) ->
+                                res.setStatus(HttpServletResponse.SC_NO_CONTENT)))
+                // API는 로그인 페이지로 리다이렉트하지 않고 401을 준다
+                .exceptionHandling(ex -> ex.authenticationEntryPoint(
+                        new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
+
+        return http.build();
+    }
+}
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -33,3 +33,8 @@
   base-url: ${MATTERMOST_URL}
   token: ${MATTERMOST_TOKEN}
   team-id: ${MATTERMOST_TEAM_ID}
+
+app:
+  admin:
+    username: ${APP_ADMIN_USERNAME}
+    password: ${APP_ADMIN_PASSWORD}
 
src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java (added)
+++ src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
@@ -0,0 +1,52 @@
+package kr.itn.itnhub.config;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+class SecurityConfigTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Test
+    void 인증없이_api를_부르면_401이다() throws Exception {
+        mvc.perform(get("/api/orgs"))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    void 올바른_비밀번호로_로그인하면_200이다() throws Exception {
+        mvc.perform(post("/api/auth/login")
+                        .param("username", "admin")
+                        .param("password", "test-password")
+                        .with(csrf()))
+                .andExpect(status().isOk());
+    }
+
+    @Test
+    void 틀린_비밀번호로_로그인하면_401이다() throws Exception {
+        mvc.perform(post("/api/auth/login")
+                        .param("username", "admin")
+                        .param("password", "wrong")
+                        .with(csrf()))
+                .andExpect(status().isUnauthorized());
+    }
+
+    @Test
+    void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
+        mvc.perform(post("/api/auth/login")
+                        .param("username", "nobody")
+                        .param("password", "nothing")
+                        .with(csrf()))
+                .andExpect(status().isUnauthorized()); // 403이 아니라 401이어야 한다
+    }
+}
Add a comment
List