여기서는 구현만을 다루기 때문에 OAuth2 등의 세부 내용은 다루지 않습니다.
해당 내용은 별개 포스트로 따로 올리려고 합니다.
DB 연동 밎 조작에는 MySQL JDBC를 사용하였습니다.
화면은 로그인, 회원가입, 메인(사용자 출력) 세 가지 화면으로 구성됩니다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form th:action="@{/login}" method="post">
<div>
<label>아이디: </label>
<input type="text" name="username" placeholder="아이디를 입력하세요">
</div>
<div>
<label>비밀번호: </label>
<input type="password" name="password" placeholder="비밀번호를 입력하세요">
</div>
<button type="submit">로그인</button>
</form>
<hr>
<div>
<a th:href="@{/oauth2/authorization/google}">
<img src="https://developers.google.com/identity/images/btn_google_signin_dark_normal_web.png" alt="구글 로그인"
style="height: 40px;">
</a>
<br>
<a th:href="@{/oauth2/authorization/kakao}">
<button style="background-color: #FEE500; border: none; height: 40px; cursor: pointer;">카카오 로그인</button>
</a>
</div>
<hr>
<p>아직 회원이 아니신가요? <a th:href="@{/register}">회원가입 하러가기</a></p>
</body>
</html>

<a>)을 누르면 href 속성에 의해 oauth를 통한 해당 인증 페이지로 이동합니다.<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
</head>
<body>
<h2>회원가입</h2>
<form th:action="@{/users/register}" method="post">
<div>
<label>아이디: </label>
<input type="text" name="username" required>
</div>
<div>
<label>비밀번호: </label>
<input type="password" name="password" required>
</div>
<button type="submit">가입하기</button>
</form>
<br>
<a th:href="@{/login}">이미 계정이 있나요? 로그인 페이지로</a>
</body>
</html>

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>홈</title>
</head>
<body>
<h1>메인 페이지</h1>
<div sec:authorize="isAuthenticated()">
<p>
<span th:if="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
th:text="${#authentication.principal.attributes['username']}">소셜유저</span>
<span th:unless="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
th:text="${#authentication.principal.username}">일반유저</span>
님, 안녕하세요!
</p>
<form th:action="@{/logout}" method="post">
<button type="submit">로그아웃</button>
</form>
</div>
<div sec:authorize="isAnonymous()">
<p>로그인이 필요합니다.</p>
<a th:href="@{/login}">로그인 페이지로 이동</a>
</div>
</body>
</html>


<div sec:authorize="isAuthenticated()">
<!-- 로그인 -->
</div>
<div sec:authorize="isAnonymous()">
<!-- 미로그인 -->
</div>
<div sec:authorize="isAuthemticated()/isAnonymous()">에 의해 판단되어 맞는 화면을 표시하게 됩니다.<div sec:authorize="isAuthenticated()">
<p>
<span th:if="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
th:text="${#authentication.principal.attributes['username']}">소셜유저</span>
<span th:unless="${#authentication.principal instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)}"
th:text="${#authentication.principal.username}">일반유저</span>
님, 안녕하세요!
</p>
<form th:action="@{/logout}" method="post">
<button type="submit">로그아웃</button>
</form>
</div>
authentication.principalth:if/unless 와 instanceof T(org.springframework.security.oauth2.core.user.OAuth2User)username을 필요로 하므로 attribute를 통해 가져옵니다. 실제 보내주는 역할은 잠시 뒤 백엔드 로직의 Service 단에서 확인할 수 있습니다.CREATE TABLE `users` (
`uid` INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
`username` VARCHAR(255) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`provider` VARCHAR(255) DEFAULT 'LOCAL',
`email` VARCHAR(255),
);
usernamepassword, email -> 따로 사용하지는 않습니다.provider@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.requestMatchers("/", "/login", "/register", "/users/**").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login")
.defaultSuccessUrl("/", true).permitAll())
//소셜 로그인(OAuth2) 설정
.oauth2Login(oauth2 -> oauth2.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.defaultSuccessUrl("/", true))
//로그아웃 설정
.logout(logout -> logout.logoutSuccessUrl("/").invalidateHttpSession(true));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
Securiry filter chain 설정은 다음과 같습니다.
csrf.disable() 상태로 설정합니다. 실제 운영 코드에서는 disable 상태이면 안됩니다. (여기서는 이 내용이 중요하지 않아 다루지 않음!!!)/, /login, /register, /users/ 요청에 대해서 별다른 인증 없이도 접근을 허용합니다. 마찬가지로 실제 서비스는 일부 접근에 대해 인증을 요구해야합니다.formLogin()으로 로그인 페이지로 커스텀 로그인 페이지(login.html)을 설정합니다. 이 설정이 로그인 요청 시 가로채어 일반/소셜 로그인을 수행하므로 컨트롤러에서는 로그인 요청을 따로 명시하지 않습니다.oauth2Login()으로 OAuth2를 이용한 로그인 기능을 설정합니다. userService(customOAuth2Userservice)를 이용해 소셜(구글과 카카오)로부터 받은 유저 정보로 회원가입 또는 로그인 등의 정보를 받습니다.logout 설정을 통해 로그아웃으로 세션을 무효화하고 메인 페이지로 이동시킵니다passwordEncoder는 비밀번호 암호화 없이 저장하기 위한 내용으로 실제 서비스에서는 반드시 암호화 과정을 거쳐야합니다. 여기서는 단순 데이터 삽입이 중요해서 암호화 과정을 생략하기 위해 작성했습니다.
@Controller
@RequiredArgsConstructor
public class PageController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/register")
public String register() {
return "register";
}
}
@Controller
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserSaveService userSaveService;
@PostMapping("/register")
public String register(@ModelAttribute UserRegisterRequest userRegisterRequest) {
userSaveService.save(userRegisterRequest);
return "redirect:/login";
}
}
//UserRegisterRequest.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserRegisterRequest {
private String username;
private String password;
}
//UserEntity.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity {
private Long uid;
private String username;
private String password;
private String email;
private String provider;
}
@Repository
@RequiredArgsConstructor
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
public void save(UserEntity userEntity) {
String sql = "INSERT INTO `users` (username, password, email, provider) VALUES (?, ?, ?, ?)";
jdbcTemplate.update(sql,
userEntity.getUsername(),
userEntity.getPassword(),
userEntity.getEmail(),
userEntity.getProvider()
);
}
public boolean existsByUsername(String username) {
String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, username);
return count != null && count > 0;
}
public boolean existsByEmail(String email) {
String sql = "SELECT COUNT(*) FROM users WHERE email = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, email);
return count != null && count > 0;
}
public Optional<UserEntity> findByUsername(String username) {
String sql = "SELECT * FROM users WHERE username = ? AND provider = 'LOCAL'";
try {
UserEntity user = jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
UserEntity.builder()
.uid(rs.getLong("uid"))
.username(rs.getString("username"))
.password(rs.getString("password"))
.provider(rs.getString("provider"))
.email(rs.getString("email"))
.build()
, username);
return Optional.ofNullable(user);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
}
save()existsByXxxxx()findByUsername()@Service
@Transactional
@RequiredArgsConstructor
public class UserSaveService {
private final UserRepository userRepository;
public void save(UserRegisterRequest userRegisterRequest) {
if (userRepository.existsByUsername(userRegisterRequest.getUsername())) {
throw new RuntimeException("username already exists.");
}
UserEntity userEntity = UserEntity.builder()
.username(userRegisterRequest.getUsername())
.password(userRegisterRequest.getPassword())
.email("NONE")
.provider("LOCAL")
.build();
userRepository.save(userEntity);
}
}
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String provider = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
String email;
String username;
// 1. 서비스별 정보 추출
if ("google".equals(provider)) {
email = oAuth2User.getAttribute("email");
username = oAuth2User.getAttribute("name");
} else { // kakao
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
email = (String) kakaoAccount.get("email");
username = (String) profile.get("nickname");
}
// 2. DB 저장 (이미 있다면 무시, 없다면 신규 저장)
saveOrUpdate(provider, email, username);
// 3. 화면 출력을 위해 'name' 속성을 강제로 주입
Map<String, Object> modifiedAttributes = new HashMap<>(oAuth2User.getAttributes());
modifiedAttributes.put("username", username); // 구글/카카오에서 가져온 실명을 'name'에 저장
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("USER")),
modifiedAttributes,
userNameAttributeName
);
}
private void saveOrUpdate(String provider, String email, String username) {
if (!userRepository.existsByEmail(email)) {
UserEntity userEntity = UserEntity.builder()
.username(username)
.password("oauth")
.email(email)
.provider(provider)
.build();
userRepository.save(userEntity);
}
}
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String provider = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
String email;
String username;
// 1. 서비스별 정보 추출
if ("google".equals(provider)) {
email = oAuth2User.getAttribute("email");
username = oAuth2User.getAttribute("name");
} else { // kakao
Map<String, Object> attributes = oAuth2User.getAttributes();
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
email = (String) kakaoAccount.get("email");
username = (String) profile.get("nickname");
}
// 2. DB 저장 (이미 있다면 무시, 없다면 신규 저장)
saveOrUpdate(provider, email, username);
// 3. 화면 출력을 위해 'name' 속성을 강제로 주입
Map<String, Object> modifiedAttributes = new HashMap<>(oAuth2User.getAttributes());
modifiedAttributes.put("username", username); // 구글/카카오에서 가져온 실명을 'name'에 저장
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("USER")),
modifiedAttributes,
userNameAttributeName
);
}
OAuth2User를 통해 소셜 로그인 대상 유저 정보를 받아옵니다.provider는 서비스 제공자 여기서는 구글 또는 카카오를 의미합니다.name님 안녕하세요라는 문구를 가져오기 때문에 username을 따로 가져와서 Attribute에 삽입합니다.return 구문에서 사용자 정보와 권한을 주고 스프링 시큐리티에게 해당 사용자 정보를 세션에 등록하라고 넘깁니다.private void saveOrUpdate(String provider, String email, String username) {
if (!userRepository.existsByEmail(email)) {
UserEntity userEntity = UserEntity.builder()
.username(username)
.password("oauth")
.email(email)
.provider(provider)
.build();
userRepository.save(userEntity);
}
}
loadUser의 2단계에서 사용하는 사용자 저장 메소드입니다.@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not exist"));
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles("USER")
.build();
}
}
UserDetails라는 객체로 만들어서 반환합니다.