この記事は未完成,未検証です
- roleによるUserのバリデーション未実装
- CSRF対策未実装
概要
React(SPA),Spring Boot(REST API)の構成において,認証認可を実装する.
JWTによる認証を行い,保護されたAPI呼び出しを行う.
構成
- React + React Router v7 + useSWR
- Spring Boot + Spring Security
- PostgreSQL
認証認可の流れ
[React Login Page]
↓ POST /api/login
↓
[Spring Boot]
認証成功 → JWT発行 → Cookieにセット (HttpOnly)
↓
[React]
useSWRによるAPI呼び出し
↓ GET /api/easys
↓
[Spring Boot]
Cookie内のJWTを検証 → 認証OK → Easy一覧返却
Spring Boot 側(認証+保護されたAPI)
application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/your_db
spring.datasource.username=your_username
spring.datasource.password=your_password
jwt.secret=十分長いランダム文字列
app.cookie.secure=false // ローカル環境
app.cookie.secure=true // 本番環境
cors.allowed-origins=http://localhost:3000
Entity
-
AppUser.java
-
Easy.java
AppUser.javapublic class AppUser { private Long id; private String email; private String password; private String role; // Getter, Setter }
Easy.javapublic class Easy { private Long id; private String title; private String detail; // Getter, Setter }
Controller
-
AuthUserController.java
- LoginRequest.java(DTO)
- RegisterRequest.java(DTO)
-
EasyController.java
AuthUserController.java@RestController @RequestMapping("/api") public class AuthUserController { @Autowired private AuthUserService authUserService; @Autowired private AuthUserRepository authUserRepository; @Value("${app.cookie.secure}") private boolean secureCookie; @PostMapping("/register") public ResponseEntity<String> register(@RequestBody RegisterRequest req) { if (authUserRepository.findByEmail(req.getEmail()).isPresent()) { return ResponseEntity .status(HttpStatus.CONFLICT) .body("このメールアドレスは既に使用されています"); } try { authUserService.register(req.getEmail(), req.getPassword(), req.getRole()); return ResponseEntity .status(HttpStatus.CREATED) .body("ユーザーを登録しました"); } catch (DuplicateKeyException e) { // 複合ユニーク制約違反など return ResponseEntity .status(HttpStatus.CONFLICT) .body("登録済みのデータです"); } catch (DataAccessException e) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body("データベースエラーが発生しました"); } catch (Exception e) { return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body("サーバー内部でエラーが発生しました"); } } @PostMapping("/login") public ResponseEntity<Void> login( @RequestBody LoginRequest request, HttpServletResponse response) throws IOException { try { authUserService.authenticate(request.getEmail(), request.getPassword()); String token = authUserService.generateToken(request.getEmail()); Cookie cookie = new Cookie("jwt", token); cookie.setHttpOnly(true); cookie.setPath("/"); cookie.setMaxAge(3600); cookie.setSecure(secureCookie); cookie.setSameSite("None"); response.addCookie(cookie); return ResponseEntity.ok().build(); } catch (AuthenticationException e) { return ResponseEntity .status(HttpStatus.UNAUTHORIZED) .build(); } } }
LoginRequest.javapublic class LoginRequest { private String email; private String password; // getter/setter }
RegisterRequest.javapublic class RegisterRequest { private String email; private String password; private String role; // getter / setter }
EasyController.java@RestController @RequestMapping("/api/easys") public class EasyController { @Autowired private EasyService easyService; @GetMapping public ResponseEntity<List<Easy>> getEasys() { return ResponseEntity.ok(easyService.getAllEasys()); } }
Service
-
AuthUserService.java
- JwtUtil.java
- JWTの生成,検証を担当するユーティリティクラス
- application.propertiesのsecretをキーとして読み込む
- generateToken(): JWTを発行
- extractEmail(): JWTからsubject(email)を取り出す
- isTokenValid(): 署名&有効期限チェック+ユーザ一致確認
- JwtCookieFilter.java
- リクエストごとにJWTをチェックし,認証コンテキストを設定するフィルタ
- Cookie から "jwt" を取得
- JwtUtil でトークン検証
- 検証成功時はSecurityContextに認証情報をセット
- JwtUtil.java
-
EasyService.java
AuthUserService.java@Service public class AuthUserService { @Autowired private AuthUserRepository authUserRepository; @Autowired private PasswordEncoder passwordEncoder; @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; public void authenticate(String email, String password) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(email, password) ); } public void register(String email, String rawPassword, String role) { String hashed = passwordEncoder.encode(rawPassword); authUserRepository.save(email, hashed, role); } public String generateToken(String email) { return jwtUtil.generateToken(email); } }
JwtUtil.java@Component public class JwtUtil { private final Key key; public JwtUtil(@Value("${jwt.secret}") String secret) { byte[] keyBytes = Decoders.BASE64.decode(secret); this.key = Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String email) { return Jwts.builder() .setSubject(email) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 3600_000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public String extractEmail(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } public boolean isTokenValid(String token, UserDetails userDetails) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); boolean sameUser = claims.getSubject().equals(userDetails.getUsername()); boolean notExpired = claims.getExpiration().after(new Date()); return sameUser && notExpired; } }
JwtCookieFilter.java@Component public class JwtCookieFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("jwt".equals(cookie.getName())) { String token = cookie.getValue(); String email = jwtUtil.extractEmail(token); if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(email); if (jwtUtil.isTokenValid(token, userDetails)) { UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } } } } } } catch (JwtException | IllegalArgumentException e) { // ログを出力する(環境に合わせて,適切な出力先を設定してください) System.err.println("JWT検証失敗: " + e.getMessage()); } chain.doFilter(request, response); } }
EasyService.java@Service public class EasyService { @Autowired private EasyRepository easyRepository; public List<Easy> getAllEasys() { return easyRepository.findAll(); } }
Repository
-
AuthUserRepository.java
-
EasyRepository.java
AuthUserRepository.java@Repository public class AuthUserRepository { @Autowired private JdbcTemplate jdbcTemplate; public Optional<AppUser> findByEmail(String email) { String sql = "SELECT * FROM app_user WHERE email = ?"; try { AppUser user = jdbcTemplate.queryForObject(sql, new Object[]{email}, (rs, rowNum) -> { AppUser u = new AppUser(); u.setId(rs.getLong("id")); u.setEmail(rs.getString("email")); u.setPassword(rs.getString("password")); u.setRole(rs.getString("role")); return u; }); return Optional.of(user); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } } public void save(String email, String password, String role) { String sql = "INSERT INTO app_user(email, password, role) VALUES (?, ?, ?)"; jdbcTemplate.update(sql, email, password, role); } }
EasyRepository.java@Repository public class EasyRepository { @Autowired private JdbcTemplate jdbcTemplate; public List<Easy> findAll() { String sql = "SELECT * FROM easy ORDER BY id"; return jdbcTemplate.query(sql, (rs, rowNum) -> { Easy e = new Easy(); e.setId(rs.getLong("id")); e.setTitle(rs.getString("title")); e.setDetail(rs.getString("detail")); return e; }); } }
Config
-
SecurityConfig.java
- Spring Securityの設定クラス
- JWT認証フィルタの追加やCSRF/CORS 設定,認可ルール(login/registerは許可,それ以外は認証必須)を行う
-
AppUserDetails.java
- Spring Securityがユーザ情報を扱うためのラッパー
- AppUserのemail/password/roleをUserDetailsインタフェースにマッピングする
-
CorsConfig.java
- CORS(クロスオリジン)設定クラス
- allowedOrigins:フロントエンドのオリジンをapplication.properties から読み込み
- allowCredentials:Cookieを含むリクエストを許可
SecurityConfig.java@Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtCookieFilter jwtFilter; @Autowired private AuthUserRepository authUserRepository; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .ignoringRequestMatchers( new AntPathRequestMatcher("/api/login"), new AntPathRequestMatcher("/api/register") ) ) .cors().and() .authorizeHttpRequests(authz -> authz .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/login", "/api/register").permitAll() .anyRequest().authenticated() ) .sessionManagement(sess -> sess .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public UserDetailsService userDetailsService() { return username -> { AppUser u = authUserRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); return new AppUserDetails(u); }; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }
AppUserDetails.javapublic class AppUserDetails implements UserDetails { private final AppUser user; public AppUserDetails(AppUser user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())); } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getEmail(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public Long getId() { return user.getId(); } }
CorsConfig.java@Configuration public class CorsConfig { @Value("${cors.allowed-origins}") private List<String> allowedOrigins; @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(allowedOrigins); config.setAllowedMethods(List.of("GET","POST","OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }
React側(認証とAPI呼び出し)
-
User.ts
User.tsinterface User { id: number, email: string, password: string, role: string }
-
Easy.ts
Easy.tsinterface Easy { id: number, title: string, detail: string }
-
React Router 構成
<Routes> <Route path="/login" element={<LoginPage />} /> <Route path="/easys" element={<EasysPage />} /> </Routes>
-
api/login.ts
api/axios.tsimport axios from 'axios'; export const apiClient = axios.create({ baseURL: 'http://localhost:8080', withCredentials: true, headers: { 'Content-Type': 'application/json', }, });
api/login.tsimport { apiClient } from './axios'; export const login = async (email: string, password: string) => { await apiClient.post('/api/login', { email, password }); };
{ withCredentials: true }
とすることで,CookieをHTTP headerに入れて扱うことができる.
-
useEasys.ts
useEasys.tsimport useSWR from 'swr'; import { apiClient } from '../api/axios'; const fetcher = (path: string) => apiClient.get(path).then(res => res.data); export const useEasys = () => { const { data, error, isLoading } = useSWR('/api/easys', fetcher); return { easys: data, isLoading, isError: error, }; };
Easyの一覧を取得するAPI
-
LoginPage.tsx
LoginPage.tsximport { useState } from 'react'; import { useNavigate } from "react-router"; import { login } from '../api/login'; export const LoginPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const navigate = useNavigate(); const onSubmit = async () => { try { await login(email, password); navigate('/easys'); } catch { alert('ログインに失敗しました'); } }; return ( <div> <input value={email} onChange={e => setEmail(e.target.value)} /> <input type="password" value={password} onChange={e => setPassword(e.target.value)} /> <button onClick={onSubmit}>ログイン</button> </div> ); };
ログイン成功後,Easy一覧に遷移する.
-
EasysPage.tsx
EasysPage.tsximport React from 'react'; import { useEasys } from '../hooks/useEasys'; export const EasysPage = () => { const { easys, isLoading, isError } = useEasys(); if (isLoading) return <div>読み込み中...</div>; if (isError) return <div>エラーが発生しました</div>; return ( <ul> {easys.map((easy: { id: number; title: string; detail: string }) => ( <li key={easy.id}> {easy.title}: {easy.detail} </li> ))} </ul> ); };