Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/spring-boot/docker-compose-api.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ services:
REDIS_DATABASE: ${REDIS_DATABASE:-0}
REDIS_PASSWORD: ${REDIS_PASSWORD:-root}
USER_PASSWORD: ${USER_PASSWORD:-qwerty123}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:client_id}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:client_secret}
SMTP_HOST: ${SMTP_HOST:-smtp.sendgrid.net}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USER:-apikey}
Expand Down
4 changes: 4 additions & 0 deletions backend/spring-boot/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.bugzkit.api.auth.oauth2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.bugzkit.api.shared.error.ErrorMessage;
import org.bugzkit.api.shared.logger.CustomLogger;
import org.bugzkit.api.shared.message.service.MessageService;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

@Component
public class OAuth2FailureHandler implements AuthenticationFailureHandler {
private final MessageService messageService;
private final CustomLogger customLogger;

@Value("${ui.url}")
private String uiUrl;

public OAuth2FailureHandler(MessageService messageService, CustomLogger customLogger) {
this.messageService = messageService;
this.customLogger = customLogger;
}

@Override
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
throws IOException {
final var errorMessage = new ErrorMessage(HttpStatus.UNAUTHORIZED);
errorMessage.addCode(getCode(exception));
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(errorMessage.toString());
response.sendRedirect(
uiUrl
+ "/auth/sign-in?error="
+ URLEncoder.encode(errorMessage.getCodes().getFirst(), StandardCharsets.UTF_8));
customLogger.error("OAuth failed", exception);
MDC.remove("REQUEST_ID");
}

private String getCode(AuthenticationException exception) {
try {
return messageService.getMessage(exception.getMessage());
} catch (Exception e) {
return messageService.getMessage("server.internalError");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.bugzkit.api.auth.oauth2;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.stream.Collectors;
import org.bugzkit.api.auth.jwt.service.AccessTokenService;
import org.bugzkit.api.auth.jwt.service.RefreshTokenService;
import org.bugzkit.api.auth.util.AuthUtil;
import org.bugzkit.api.shared.logger.CustomLogger;
import org.bugzkit.api.user.payload.dto.RoleDTO;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final AccessTokenService accessTokenService;
private final RefreshTokenService refreshTokenService;
private final CustomLogger customLogger;

@Value("${ui.url}")
private String uiUrl;

@Value("${jwt.access-token.duration}")
private int accessTokenDuration;

@Value("${jwt.refresh-token.duration}")
private int refreshTokenDuration;

public OAuth2SuccessHandler(
AccessTokenService accessTokenService,
RefreshTokenService refreshTokenService,
CustomLogger customLogger) {
this.accessTokenService = accessTokenService;
this.refreshTokenService = refreshTokenService;
this.customLogger = customLogger;
}

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
final var userPrincipal = (OAuth2UserPrincipal) authentication.getPrincipal();
final var roleDTOs =
userPrincipal.getAuthorities().stream()
.map(authority -> new RoleDTO(authority.getAuthority()))
.collect(Collectors.toSet());
final var ipAddress = AuthUtil.getUserIpAddress(request);
final var accessToken = accessTokenService.create(userPrincipal.getId(), roleDTOs);
final var refreshToken = refreshTokenService.create(userPrincipal.getId(), roleDTOs, ipAddress);
final var accessTokenCookie =
AuthUtil.createCookie("accessToken", accessToken, accessTokenDuration);
final var refreshTokenCookie =
AuthUtil.createCookie("refreshToken", refreshToken, refreshTokenDuration);
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
response.sendRedirect(uiUrl);
customLogger.info("Finished");
MDC.remove("REQUEST_ID");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.bugzkit.api.auth.oauth2;

import java.util.Map;
import org.bugzkit.api.auth.security.UserPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;

public class OAuth2UserPrincipal extends UserPrincipal implements OAuth2User {
private final Map<String, Object> attributes;

public OAuth2UserPrincipal(UserPrincipal principal, Map<String, Object> attributes) {
super(
principal.getId(),
principal.getUsername(),
principal.getEmail(),
principal.getPassword(),
principal.isEnabled(),
principal.isAccountNonLocked(),
principal.getCreatedAt(),
principal.getAuthorities());
this.attributes = attributes;
}

@Override
public String getName() {
return this.getEmail();
}

@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.bugzkit.api.auth.oauth2;

import java.util.Collections;
import java.util.UUID;
import org.bugzkit.api.auth.security.UserPrincipal;
import org.bugzkit.api.shared.logger.CustomLogger;
import org.bugzkit.api.user.model.Role.RoleName;
import org.bugzkit.api.user.model.User;
import org.bugzkit.api.user.repository.RoleRepository;
import org.bugzkit.api.user.repository.UserRepository;
import org.slf4j.MDC;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class OAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final CustomLogger customLogger;

public OAuth2UserService(
UserRepository userRepository, RoleRepository roleRepository, CustomLogger customLogger) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.customLogger = customLogger;
}

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
final var requestId = UUID.randomUUID().toString();
MDC.put("REQUEST_ID", requestId);
customLogger.info("Called");
final var oAuthUser = super.loadUser(userRequest);
final String email = oAuthUser.getAttribute("email");
final boolean emailVerified = Boolean.TRUE.equals(oAuthUser.getAttribute("email_verified"));
if (!emailVerified) throw new DisabledException("user.notActive");
final var user = userRepository.findWithRolesByEmail(email).orElseGet(() -> createUser(email));
if (user.getUsername() == null) user.setUsername(email);
return new OAuth2UserPrincipal(UserPrincipal.create(user), oAuthUser.getAttributes());
}

private User createUser(String email) {
final var roles =
roleRepository
.findByName(RoleName.USER)
.orElseThrow(() -> new RuntimeException("user.roleNotFound"));
final var user =
User.builder().email(email).active(true).roles(Collections.singleton(roles)).build();
return userRepository.save(user);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.bugzkit.api.auth.security;

import org.bugzkit.api.shared.error.exception.UnauthorizedException;
import org.bugzkit.api.shared.error.exception.ResourceNotFoundException;
import org.bugzkit.api.user.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
Expand All @@ -20,7 +20,7 @@ public UserDetails loadUserByUserId(Long userId) {
return userRepository
.findById(userId)
.map(UserPrincipal::create)
.orElseThrow(() -> new UnauthorizedException("auth.unauthorized"));
.orElseThrow(() -> new ResourceNotFoundException("user.notFound"));
}

@Override
Expand All @@ -29,6 +29,6 @@ public UserDetails loadUserByUsername(String username) {
return userRepository
.findByUsernameOrEmail(username, username)
.map(UserPrincipal::create)
.orElseThrow(() -> new UnauthorizedException("auth.unauthorized"));
.orElseThrow(() -> new ResourceNotFoundException("user.notFound"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public static ResponseCookie createCookie(String name, String value, int maxAge)
.secure(true)
.path("/")
.maxAge(maxAge)
.sameSite(SameSite.STRICT.attributeValue())
.sameSite(SameSite.LAX.attributeValue())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package org.bugzkit.api.shared.config;

import org.bugzkit.api.auth.oauth2.OAuth2FailureHandler;
import org.bugzkit.api.auth.oauth2.OAuth2SuccessHandler;
import org.bugzkit.api.auth.oauth2.OAuth2UserService;
import org.bugzkit.api.auth.security.JWTFilter;
import org.bugzkit.api.auth.security.UserDetailsServiceImpl;
import org.bugzkit.api.shared.constants.Path;
Expand All @@ -23,6 +26,7 @@
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private static final String[] OAUTH2_WHITELIST = {"/oauth2/authorization/google"};
private static final String[] AUTH_WHITELIST = {
Path.AUTH + "/register",
Path.AUTH + "/tokens",
Expand All @@ -41,14 +45,23 @@ public class SecurityConfig {
private final JWTFilter jwtFilter;
private final UserDetailsServiceImpl userDetailsService;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
private final OAuth2UserService oAuth2UserService;

public SecurityConfig(
JWTFilter jwtFilter,
UserDetailsServiceImpl userDetailsService,
CustomAuthenticationEntryPoint customAuthenticationEntryPoint) {
CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
OAuth2SuccessHandler oAuth2SuccessHandler,
OAuth2FailureHandler oAuth2FailureHandler,
OAuth2UserService oAuth2UserService) {
this.jwtFilter = jwtFilter;
this.userDetailsService = userDetailsService;
this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
this.oAuth2FailureHandler = oAuth2FailureHandler;
this.oAuth2UserService = oAuth2UserService;
}

@Bean
Expand Down Expand Up @@ -77,8 +90,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.permitAll()
.requestMatchers(ACTUATOR_WHITELIST)
.permitAll()
.requestMatchers(OAUTH2_WHITELIST)
.permitAll()
.anyRequest()
.authenticated())
.oauth2Login(
oauth ->
oauth
.userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService))
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler))
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterAfter(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@ public class User implements Serializable {
@Column(name = "user_id")
private Long id;

@Column(unique = true, nullable = false)
@Column(unique = true)
private String username;

@Column(unique = true, nullable = false)
private String email;

@Column(nullable = false)
private String password;

@Builder.Default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "roles")
Optional<User> findWithRolesByUsername(String username);

@EntityGraph(attributePaths = "roles")
Optional<User> findWithRolesByEmail(String email);

Optional<User> findByEmail(String email);

Optional<User> findByUsernameOrEmail(String username, String email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ private void deleteAuthTokens(Long userId) {
}

private void setUsername(User user, String username) {
if (user.getUsername().equals(username)) return;
if (user.getUsername() != null && user.getUsername().equals(username)) return;
if (userRepository.existsByUsername(username))
throw new ConflictException("user.usernameExists");

Expand Down
7 changes: 7 additions & 0 deletions backend/spring-boot/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ spring:
security:
user:
password: ${USER_PASSWORD:qwerty123}
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID:client_id}
client-secret: ${GOOGLE_CLIENT_SECRET:client_secret}
scope: profile,email
mail:
password: ${SMTP_PASSWORD:password}

Expand Down
7 changes: 7 additions & 0 deletions backend/spring-boot/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ spring:
security:
user:
password: ${user_password} # remove when liquibase is added
oauth2:
client:
registration:
google:
client-id: ${google_client_id}
client-secret: ${google_client_secret}
scope: profile,email
mail:
password: ${smtp_password}

Expand Down
Loading