diff --git a/server/build.gradle b/server/build.gradle index 7f605f43..1a0f7640 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -79,12 +79,12 @@ repositories { dependencies { compileOnly 'org.projectlombok:lombok' - implementation 'org.springframework.boot:spring-boot-starter-data-rest' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.jsoup:jsoup:1.17.2' diff --git a/server/src/main/java/dev/findfirst/core/repository/jdbc/BookmarkJDBCRepository.java b/server/src/main/java/dev/findfirst/core/repository/jdbc/BookmarkJDBCRepository.java index 27048e74..78f0df16 100644 --- a/server/src/main/java/dev/findfirst/core/repository/jdbc/BookmarkJDBCRepository.java +++ b/server/src/main/java/dev/findfirst/core/repository/jdbc/BookmarkJDBCRepository.java @@ -23,7 +23,7 @@ public interface BookmarkJDBCRepository public Page findAllByUserId(int userId, Pageable pageable); - @Query("SELECT b FROM Bookmark b WHERE b.screenshotUrl IS NULL OR TRIM(b.screenshotUrl)=''") + @Query("SELECT * FROM Bookmark b WHERE b.screenshot_url IS NULL OR TRIM(b.screenshot_url)=''") List findBookmarksWithEmptyOrBlankScreenShotUrl(); @Query("select * from bookmark where to_tsvector(title) @@ to_tsquery(:keywords) AND bookmark.user_id = :userID") diff --git a/server/src/main/java/dev/findfirst/security/conditions/OAuthClientsCondition.java b/server/src/main/java/dev/findfirst/security/conditions/OAuthClientsCondition.java new file mode 100644 index 00000000..f0505479 --- /dev/null +++ b/server/src/main/java/dev/findfirst/security/conditions/OAuthClientsCondition.java @@ -0,0 +1,23 @@ +package dev.findfirst.security.conditions; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +public class OAuthClientsCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + Binder binder = Binder.get(context.getEnvironment()); + Map properties = binder + .bind("spring.security.oauth2.client.registration", Bindable.mapOf(String.class, String.class)) + .orElse(Collections.emptyMap()); + return !properties.isEmpty(); + } + +} diff --git a/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java b/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java index 21117c6f..70d8a40a 100644 --- a/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java +++ b/server/src/main/java/dev/findfirst/security/config/SecSecurityConfig.java @@ -1,10 +1,14 @@ package dev.findfirst.security.config; +import static org.springframework.security.config.Customizer.withDefaults; + import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import dev.findfirst.security.conditions.OAuthClientsCondition; import dev.findfirst.security.filters.CookieAuthenticationFilter; import dev.findfirst.security.jwt.AuthEntryPointJwt; +import dev.findfirst.security.oauth2client.handlers.Oauth2LoginSuccessHandler; import dev.findfirst.security.userauth.service.UserDetailsServiceImpl; import com.nimbusds.jose.jwk.JWK; @@ -16,7 +20,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; @@ -33,6 +39,7 @@ import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration @EnableWebSecurity @@ -50,6 +57,8 @@ public class SecSecurityConfig { private final AuthEntryPointJwt unauthorizedHandler; + private final Oauth2LoginSuccessHandler oauth2Success; + @Bean public CookieAuthenticationFilter cookieJWTAuthFilter() { return new CookieAuthenticationFilter(); @@ -77,19 +86,46 @@ public DaoAuthenticationProvider authenticationProvider() { } @Bean + @Order(1) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/user/**").permitAll() - .anyRequest().authenticated()); + + http.securityMatcher("/user/**", "/api/**") // Include /login + .authorizeHttpRequests(auth -> auth.requestMatchers("/").denyAll()) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/user/**").permitAll() + .anyRequest().authenticated()); + + // stateless cookie app http.csrf(csrf -> csrf.disable()) - .httpBasic(httpBasicCustomizer -> httpBasicCustomizer - .authenticationEntryPoint(unauthorizedHandler)) - .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder()))) .sessionManagement( session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(unauthorizedHandler) - .accessDeniedHandler(new BearerTokenAccessDeniedHandler())); - http.authenticationProvider(authenticationProvider()); - http.addFilterBefore(cookieJWTAuthFilter(), UsernamePasswordAuthenticationFilter.class); + .oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder()))); + + http.httpBasic( + httpBasicCustomizer -> httpBasicCustomizer.authenticationEntryPoint(unauthorizedHandler)) + + // use this exeception only for /user/signin + .exceptionHandling(exceptions -> exceptions + .defaultAuthenticationEntryPointFor(unauthorizedHandler, + new AntPathRequestMatcher("/user/signin")) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler())) + + .authenticationProvider(authenticationProvider()) + + // filters + .addFilterBefore(cookieJWTAuthFilter(), UsernamePasswordAuthenticationFilter.class); + + // wrap it all up. + return http.build(); + } + + @Bean + @Order(2) + @Conditional(OAuthClientsCondition.class) + public SecurityFilterChain oauth2ClientsFilterChain(HttpSecurity http) throws Exception { + http.securityMatcher("/oauth2/**", "/login/**", "/error/**", "/*") // Apply only for OAuth paths + .oauth2Login(oauth -> oauth.successHandler(oauth2Success)) + .formLogin(withDefaults()); return http.build(); } diff --git a/server/src/main/java/dev/findfirst/security/jwt/service/RefreshTokenService.java b/server/src/main/java/dev/findfirst/security/jwt/service/RefreshTokenService.java index a1357509..f733cc47 100644 --- a/server/src/main/java/dev/findfirst/security/jwt/service/RefreshTokenService.java +++ b/server/src/main/java/dev/findfirst/security/jwt/service/RefreshTokenService.java @@ -31,12 +31,14 @@ public Optional findByToken(String token) { } public RefreshToken createRefreshToken(User user) { + return createRefreshToken(user.getUserId()); + } - RefreshToken refreshToken = new RefreshToken(null, AggregateReference.to(user.getUserId()), + public RefreshToken createRefreshToken(int userID) { + RefreshToken refreshToken = new RefreshToken(null, AggregateReference.to(userID), UUID.randomUUID().toString(), Instant.now().plusMillis(refreshTokenDurationMs)); - refreshToken = refreshTokenRepository.save(refreshToken); - return refreshToken; + return refreshTokenRepository.save(refreshToken); } public RefreshToken verifyExpiration(RefreshToken token) { diff --git a/server/src/main/java/dev/findfirst/security/jwt/service/TokenService.java b/server/src/main/java/dev/findfirst/security/jwt/service/TokenService.java new file mode 100644 index 00000000..fb1ca324 --- /dev/null +++ b/server/src/main/java/dev/findfirst/security/jwt/service/TokenService.java @@ -0,0 +1,80 @@ +package dev.findfirst.security.jwt.service; + +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.stereotype.Service; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import dev.findfirst.users.repository.UserRepo; +import dev.findfirst.users.model.user.URole; +import dev.findfirst.security.userauth.utils.Constants; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TokenService { + + @Value("${findfirst.app.jwtExpirationMs}") + private int jwtExpirationMs; + + @Value("${jwt.public.key}") + private RSAPublicKey key; + + @Value("${jwt.private.key}") + private RSAPrivateKey priv; + + private final UserRepo userRepo; + + + public String generateTokenFromUser(int userId) { + Instant now = Instant.now(); + var user = userRepo.findById(userId).orElseThrow(); + String email = user.getEmail(); + Integer roleId = user.getRole().getId(); + var roleName = URole.values()[roleId].toString(); + JwtClaimsSet claims = JwtClaimsSet.builder().issuer("self").issuedAt(Instant.now()) + .expiresAt(now.plusSeconds(jwtExpirationMs)).subject(email).claim("scope", email) + .claim(Constants.ROLE_ID_CLAIM, roleId).claim(Constants.ROLE_NAME_CLAIM, roleName) + .claim("userId", userId).build(); + return jwtEncoder().encode(JwtEncoderParameters.from(claims)).getTokenValue(); + } + + + JwtEncoder jwtEncoder() { + JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build(); + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwks); + } + + // private String extractUserId(Authentication authentication) { + // if (authentication.getPrincipal() instanceof UserDetails) { + // String details = ((UserDetails) authentication.getPrincipal()).getUsername(); + // System.out.println("If details " + details); + // return details; + // } else if (authentication.getPrincipal() instanceof DefaultOAuth2User) { + // DefaultOAuth2User oAuth2User = (DefaultOAuth2User) + // authentication.getPrincipal(); + // String details = oAuth2User.getAttribute("id"); + // System.out.println("Else details " + details); + // return details; + // } + // return null; + // } +} diff --git a/server/src/main/java/dev/findfirst/security/oauth2client/OauthUserService.java b/server/src/main/java/dev/findfirst/security/oauth2client/OauthUserService.java new file mode 100644 index 00000000..eff8ab00 --- /dev/null +++ b/server/src/main/java/dev/findfirst/security/oauth2client/OauthUserService.java @@ -0,0 +1,98 @@ +package dev.findfirst.security.oauth2client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.rmi.UnexpectedException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import dev.findfirst.security.userauth.models.payload.request.SignupRequest; +import dev.findfirst.users.exceptions.EmailAlreadyRegisteredException; +import dev.findfirst.users.exceptions.UserNameTakenException; +import dev.findfirst.users.repository.UserRepo; +import dev.findfirst.users.service.UserManagementService; +import dev.findfirst.users.model.user.URole; +import dev.findfirst.users.model.user.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OauthUserService implements OAuth2UserService { + + final UserRepo userRepo; + final UserManagementService ums; + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); + User user = null; + + // user exists in database by email + var attrs = oAuth2User.getAttributes(); + var email = (String) attrs.get("email"); + var username = (String) attrs.get("login"); + String registrationId = userRequest.getClientRegistration().getClientId(); + if (email != null && !email.isEmpty()) { + log.debug("attempt login with email {}", email); + // user = userRepo.findByEmail(email).or() + } else if (username != null && !username.isEmpty()) { + log.debug("looking up if user exist with username {}", username); + var userOpt = userRepo.findByUsername(username); + + var oauth2PlaceholderEmail = username + registrationId; + if (userOpt.isEmpty()) { + try { + log.debug("creating a new user for oauth2"); + user = ums + .createNewUserAccount(new SignupRequest(username, oauth2PlaceholderEmail, UUID.randomUUID().toString())); + } catch (UnexpectedException | UserNameTakenException | EmailAlreadyRegisteredException e) { + log.debug("errors occured: {}", e.getMessage()); + } + } else { + user = userOpt.get(); + } + } + if (user.getUserId() != null) { + GrantedAuthority authority = new SimpleGrantedAuthority(URole.values()[user.getRole().getId()].toString()); + String userNameAttributeName = userRequest + .getClientRegistration() + .getProviderDetails() + .getUserInfoEndpoint() + .getUserNameAttributeName(); + log.debug("USER ATTRIBUTE NAME: {}", userNameAttributeName); + var attributes = customAttribute(attrs, userNameAttributeName, user.getUserId(), registrationId); + return new DefaultOAuth2User(Collections.singletonList(authority), attributes, userNameAttributeName); + } + + return oAuth2User; + } + + private Map customAttribute( + Map attributes, + String userNameAttributeName, + int userID, + String registrationId) { + Map customAttribute = new HashMap<>(); + customAttribute.put(userNameAttributeName, attributes.get(userNameAttributeName)); + customAttribute.put("provider", registrationId); + customAttribute.put("userID", userID); + return customAttribute; + } + +} diff --git a/server/src/main/java/dev/findfirst/security/oauth2client/handlers/Oauth2LoginSuccessHandler.java b/server/src/main/java/dev/findfirst/security/oauth2client/handlers/Oauth2LoginSuccessHandler.java new file mode 100644 index 00000000..1f08e786 --- /dev/null +++ b/server/src/main/java/dev/findfirst/security/oauth2client/handlers/Oauth2LoginSuccessHandler.java @@ -0,0 +1,59 @@ +package dev.findfirst.security.oauth2client.handlers; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import dev.findfirst.security.jwt.service.RefreshTokenService; +import dev.findfirst.security.jwt.service.TokenService; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class Oauth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + @Value("${findfirst.app.frontend-url}") + private String redirectURL; + + @Value("${findfirst.app.domain}") + private String domain; + + @Value("${findfirst.secure-cookies:true}") + private boolean secure; + + private final TokenService ts; + private final RefreshTokenService rt; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws ServletException, IOException { + + DefaultOAuth2User principal = (DefaultOAuth2User) authentication.getPrincipal(); + var userID = (Integer) principal.getAttributes().get("userID"); + + var jwt = ts.generateTokenFromUser(userID); + + ResponseCookie cookie = ResponseCookie.from("findfirst", jwt).secure(secure).path("/") + .domain(domain).httpOnly(true).build(); + + response.addHeader("Set-Cookie", cookie.toString()); + response.getWriter().write(""" + { refreshToken: %s} + """.formatted(rt.createRefreshToken(userID))); + getRedirectStrategy().sendRedirect(request, response, redirectURL); + + } + +} diff --git a/server/src/main/java/dev/findfirst/users/controller/UserController.java b/server/src/main/java/dev/findfirst/users/controller/UserController.java index aeed1ff0..cd74f0d0 100644 --- a/server/src/main/java/dev/findfirst/users/controller/UserController.java +++ b/server/src/main/java/dev/findfirst/users/controller/UserController.java @@ -115,7 +115,7 @@ public ResponseEntity resetPassword(@RequestParam @Email String email) { } } - @GetMapping("changePassword") + @GetMapping("/changePassword") public ResponseEntity frontendPasswordWithToken(@RequestParam("token") String token) throws URISyntaxException { @@ -130,7 +130,7 @@ public ResponseEntity frontendPasswordWithToken(@RequestParam("token") S return new ResponseEntity<>(httpHeaders, HttpStatus.SEE_OTHER); } - @PostMapping("changePassword") + @PostMapping("/changePassword") public ResponseEntity passwordChange(@RequestBody TokenPassword tokenPassword) { try { pwdService.changePassword(tokenPassword); diff --git a/server/src/main/java/dev/findfirst/users/service/UserManagementService.java b/server/src/main/java/dev/findfirst/users/service/UserManagementService.java index 8ddbcae8..6dc717b1 100644 --- a/server/src/main/java/dev/findfirst/users/service/UserManagementService.java +++ b/server/src/main/java/dev/findfirst/users/service/UserManagementService.java @@ -31,6 +31,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.jdbc.core.mapping.AggregateReference; +import dev.findfirst.security.jwt.service.TokenService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; @@ -48,10 +49,7 @@ public class UserManagementService { private final PasswordTokenRepository passwordTokenRepository; private final RefreshTokenService refreshTokenService; private final PasswordEncoder passwdEncoder; - private final JwtEncoder encoder; - - @Value("${findfirst.app.jwtExpirationMs}") - private int jwtExpirationMs; + private final TokenService ts; @Value("${findfirst.upload.location}") private String uploadLocation; @@ -188,6 +186,7 @@ public User createNewUserAccount(SignupRequest signupRequest) // create a new tenant try { + log.debug("new user - saving user"); return saveUser(user); } catch (Exception e) { // If any exception occurs we should delete the records that were just made. @@ -197,17 +196,12 @@ public User createNewUserAccount(SignupRequest signupRequest) } } + /** + * Wrapper for the Token Service. + * @param userId the userId for token generation. + */ public String generateTokenFromUser(int userId) { - Instant now = Instant.now(); - var user = userRepo.findById(userId).orElseThrow(); - String email = user.getEmail(); - Integer roleId = user.getRole().getId(); - var roleName = URole.values()[roleId].toString(); - JwtClaimsSet claims = JwtClaimsSet.builder().issuer("self").issuedAt(Instant.now()) - .expiresAt(now.plusSeconds(jwtExpirationMs)).subject(email).claim("scope", email) - .claim(Constants.ROLE_ID_CLAIM, roleId).claim(Constants.ROLE_NAME_CLAIM, roleName) - .claim("userId", userId).build(); - return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); + return ts.generateTokenFromUser(userId); } public SigninTokens signinUser(String authorization) throws NoUserFoundException { diff --git a/server/src/main/resources/application-dev.properties b/server/src/main/resources/application-dev.properties index 7194011b..22f42a3a 100644 --- a/server/src/main/resources/application-dev.properties +++ b/server/src/main/resources/application-dev.properties @@ -28,5 +28,12 @@ spring.mail.port=1025 spring.mail.username=findfirst@localmail.dev # Dev tools +spring.devtools.restart.enabled=true spring.devtools.restart.pollInterval=10s spring.flyway.locations=classpath:db/migration,classpath:db/dev + + +# spring.security.oauth2.client.registration.github.client-secret.github.redirectUri=localhost:9000/user/oauth/code/github +spring + +logging.level.org.springframework.security=TRACE