Basic Users and authentication.

This commit is contained in:
JesseBrault0709 2024-06-20 15:53:06 +02:00
parent 34c13d3315
commit bf2b7138ca
25 changed files with 616 additions and 37 deletions

View File

@ -4,8 +4,8 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller("/")
public class GreetingController {
@Controller
public final class GreetingController {
@GetMapping("/greeting")
@ResponseBody

View File

@ -1,15 +1,12 @@
package app.mealsmadeeasy.api;
import io.jsonwebtoken.Jwts;
import app.mealsmadeeasy.api.user.UserService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import javax.crypto.SecretKey;
import java.util.Set;
@SpringBootApplication
public class MealsMadeEasyApiApplication {
@ -18,21 +15,17 @@ public class MealsMadeEasyApiApplication {
SpringApplication.run(MealsMadeEasyApiApplication.class, args);
}
@Bean
public SecretKey secretKey() {
return Jwts.SIG.HS256.key().build();
private final UserService userService;
public MealsMadeEasyApiApplication(UserService userService) {
this.userService = userService;
}
@Bean
public UserDetailsService userDetailsService() {
@SuppressWarnings("deprecation")
UserDetails testUser = User
.withDefaultPasswordEncoder()
.username("test")
.password("test")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(testUser);
public CommandLineRunner addTestUser() {
return args -> {
this.userService.createUser("test", "test@test.com", "test", Set.of());
};
}
}

View File

@ -0,0 +1,44 @@
package app.mealsmadeeasy.api.auth;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth")
public final class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ResponseEntity<LoginView> login(@RequestBody LoginBody loginBody, HttpServletResponse response) {
try {
final LoginDetails loginDetails = this.authService.login(loginBody.getUsername(), loginBody.getPassword());
final String serializedToken = loginDetails.getRefreshToken().getToken();
final ResponseCookie refreshCookie = ResponseCookie.from("refresh-token", serializedToken)
.httpOnly(true)
.secure(true)
.maxAge(loginDetails.getRefreshToken().getLifetime())
.build();
final LoginView loginView = new LoginView(
loginDetails.getUsername(), loginDetails.getAccessToken().getToken()
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(loginView);
} catch (LoginException loginException) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}

View File

@ -0,0 +1,5 @@
package app.mealsmadeeasy.api.auth;
public interface AuthService {
LoginDetails login(String username, String password) throws LoginException;
}

View File

@ -0,0 +1,36 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Service;
@Service
public final class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthServiceImpl(AuthenticationManager authenticationManager, JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
@Override
public LoginDetails login(String username, String password) throws LoginException {
try {
this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
username,
password
));
return new LoginDetails(
username,
this.jwtService.generateAccessToken(username),
this.jwtService.generateRefreshToken(username)
);
} catch (Exception e) {
throw new LoginException(e);
}
}
}

View File

@ -0,0 +1,24 @@
package app.mealsmadeeasy.api.auth;
public final class LoginBody {
private String username;
private String password;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -0,0 +1,29 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.AuthToken;
public final class LoginDetails {
private final String username;
private final AuthToken accessToken;
private final AuthToken refreshToken;
public LoginDetails(String username, AuthToken accessToken, AuthToken refreshToken) {
this.username = username;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getUsername() {
return this.username;
}
public AuthToken getAccessToken() {
return this.accessToken;
}
public AuthToken getRefreshToken() {
return this.refreshToken;
}
}

View File

@ -0,0 +1,17 @@
package app.mealsmadeeasy.api.auth;
public final class LoginException extends Exception {
public LoginException(String message) {
super(message);
}
public LoginException(String message, Throwable cause) {
super(message, cause);
}
public LoginException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,21 @@
package app.mealsmadeeasy.api.auth;
public final class LoginView {
private final String username;
private final String accessToken;
public LoginView(String username, String accessToken) {
this.username = username;
this.accessToken = accessToken;
}
public String getUsername() {
return this.username;
}
public String getAccessToken() {
return this.accessToken;
}
}

View File

@ -0,0 +1,21 @@
package app.mealsmadeeasy.api.security;
public final class AuthToken {
private final String token;
private final long lifetime;
public AuthToken(String token, long lifetime) {
this.token = token;
this.lifetime = lifetime;
}
public String getToken() {
return this.token;
}
public long getLifetime() {
return this.lifetime;
}
}

View File

@ -0,0 +1,11 @@
package app.mealsmadeeasy.api.security;
import app.mealsmadeeasy.api.user.User;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface JpaUserDetailsService extends UserDetailsService {
User createUser(User user);
User updateUser(User user);
void deleteUser(String username);
void deleteUser(User user);
}

View File

@ -0,0 +1,47 @@
package app.mealsmadeeasy.api.security;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserEntity;
import app.mealsmadeeasy.api.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public final class JpaUserDetailsServiceImpl implements JpaUserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public User createUser(User user) {
return this.userRepository.save((UserEntity) user);
}
@Override
public User updateUser(User user) {
return this.userRepository.save((UserEntity) user);
}
@Override
public void deleteUser(String username) {
final UserEntity user = this.userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("No such User with username: " + username));
this.userRepository.delete(user);
}
@Override
public void deleteUser(User user) {
this.userRepository.delete((UserEntity) user);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("No such User with username: " + username));
}
}

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.security;
public interface JwtService {
String generateToken(String username);
AuthToken generateAccessToken(String username);
AuthToken generateRefreshToken(String username);
}

View File

@ -2,6 +2,7 @@ package app.mealsmadeeasy.api.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Serializer;
import io.jsonwebtoken.jackson.io.JacksonSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@ -9,34 +10,52 @@ import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
@Service
public final class JwtServiceImpl implements JwtService {
private final ObjectMapper objectMapper;
private final long tokenLifetime;
private final Serializer<Map<String, ?>> serializer;
private final long accessTokenLifetime;
private final long refreshTokenLifetime;
private final SecretKey secretKey;
public JwtServiceImpl(
ObjectMapper objectMapper,
@Value("${app.mealsmadeeasy.api.security.token-lifetime}") Long tokenLifetime,
@Value("${app.mealsmadeeasy.api.security.access-token-lifetime}") Long accessTokenLifetime,
@Value("${app.mealsmadeeasy.api.security.refresh-token-lifetime}") Long refreshTokenLifetime,
SecretKey secretKey
) {
this.objectMapper = objectMapper;
this.tokenLifetime = tokenLifetime;
this.serializer = new JacksonSerializer<>();
this.accessTokenLifetime = accessTokenLifetime;
this.refreshTokenLifetime = refreshTokenLifetime;
this.secretKey = secretKey;
}
@Override
public String generateToken(String username) {
public AuthToken generateAccessToken(String username) {
final Instant now = Instant.now();
return Jwts.builder()
final String token = Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(Instant.now().plusSeconds(this.tokenLifetime)))
.expiration(Date.from(now.plusSeconds(this.accessTokenLifetime)))
.signWith(this.secretKey)
.json(new JacksonSerializer<>(this.objectMapper))
.json(this.serializer)
.compact();
return new AuthToken(token, this.accessTokenLifetime);
}
@Override
public AuthToken generateRefreshToken(String username) {
final Instant now = Instant.now();
final String token = Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(this.refreshTokenLifetime)))
.signWith(this.secretKey)
.json(this.serializer)
.compact();
return new AuthToken(token, this.refreshTokenLifetime);
}
}

View File

@ -0,0 +1,17 @@
package app.mealsmadeeasy.api.security;
import io.jsonwebtoken.Jwts;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.crypto.SecretKey;
@Configuration
public class KeyConfiguration {
@Bean
public SecretKey secretKey() {
return Jwts.SIG.HS256.key().build();
}
}

View File

@ -3,6 +3,9 @@ package app.mealsmadeeasy.api.security;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -10,6 +13,8 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@ -20,16 +25,16 @@ import javax.crypto.SecretKey;
public class SecurityConfiguration {
private final SecretKey secretKey;
private final UserDetailsService userDetailsService;
private final JpaUserDetailsService jpaUserDetailsService;
public SecurityConfiguration(SecretKey secretKey, UserDetailsService userDetailsService) {
public SecurityConfiguration(SecretKey secretKey, JpaUserDetailsService jpaUserDetailsService) {
this.secretKey = secretKey;
this.userDetailsService = userDetailsService;
this.jpaUserDetailsService = jpaUserDetailsService;
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers("/greeting");
return web -> web.ignoring().requestMatchers("/greeting", "/auth/login");
}
@Bean
@ -46,10 +51,33 @@ public class SecurityConfiguration {
});
});
httpSecurity.addFilterBefore(
new JwtFilter(this.secretKey, this.userDetailsService),
new JwtFilter(this.secretKey, this.jpaUserDetailsService),
UsernamePasswordAuthenticationFilter.class
);
return httpSecurity.build();
}
@Bean
public UserDetailsService userDetailsService() {
return this.jpaUserDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10);
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
final var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(this.userDetailsService());
provider.setPasswordEncoder(this.passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(this.daoAuthenticationProvider());
}
}

View File

@ -0,0 +1,18 @@
package app.mealsmadeeasy.api.user;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Set;
public sealed interface User extends UserDetails permits UserEntity {
Long getId();
String getEmail();
void setEmail(String email);
void addAuthority(UserGrantedAuthority userGrantedAuthority);
void addAuthorities(Set<? extends UserGrantedAuthority> userGrantedAuthorities);
void removeAuthority(UserGrantedAuthority userGrantedAuthority);
}

View File

@ -0,0 +1,144 @@
package app.mealsmadeeasy.api.user;
import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
@Entity(name = "User")
public final class UserEntity implements User {
public static UserEntity getDefaultDraft() {
final var user = new UserEntity();
user.setEnabled(true);
user.setExpired(false);
user.setLocked(false);
user.setCredentialsExpired(false);
return user;
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@OneToMany(fetch = FetchType.EAGER, mappedBy = "userEntity")
private final Set<UserGrantedAuthorityEntity> authorities = new HashSet<>();
@Column(nullable = false)
private Boolean enabled;
@Column(nullable = false)
private Boolean expired;
@Column(nullable = false)
private Boolean locked;
@Column(nullable = false)
private Boolean credentialsExpired;
@Override
public Long getId() {
return this.id;
}
public void setId(Long id) {
this.id = id;
}
@Override
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String getEmail() {
return this.email;
}
@Override
public void setEmail(String email) {
this.email = email;
}
@Override
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public void addAuthority(UserGrantedAuthority userGrantedAuthority) {
this.authorities.add((UserGrantedAuthorityEntity) userGrantedAuthority);
}
@Override
public void addAuthorities(Set<? extends UserGrantedAuthority> userGrantedAuthorities) {
userGrantedAuthorities.forEach(this::addAuthority);
}
@Override
public void removeAuthority(UserGrantedAuthority userGrantedAuthority) {
this.authorities.remove((UserGrantedAuthorityEntity) userGrantedAuthority);
}
@Override
public boolean isAccountNonExpired() {
return !this.expired;
}
public void setExpired(Boolean expired) {
this.expired = expired;
}
@Override
public boolean isAccountNonLocked() {
return !this.locked;
}
public void setLocked(Boolean locked) {
this.locked = locked;
}
@Override
public boolean isCredentialsNonExpired() {
return !this.credentialsExpired;
}
public void setCredentialsExpired(Boolean credentialsExpired) {
this.credentialsExpired = credentialsExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
}

View File

@ -0,0 +1,5 @@
package app.mealsmadeeasy.api.user;
import org.springframework.security.core.GrantedAuthority;
public interface UserGrantedAuthority extends GrantedAuthority {}

View File

@ -0,0 +1,24 @@
package app.mealsmadeeasy.api.user;
import jakarta.persistence.*;
@Entity
public final class UserGrantedAuthorityEntity implements UserGrantedAuthority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;
private String authority;
@ManyToOne
@JoinColumn(name = "user_entity_id")
private UserEntity userEntity;
@Override
public String getAuthority() {
return this.authority;
}
}

View File

@ -0,0 +1,5 @@
package app.mealsmadeeasy.api.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserGrantedAuthorityRepository extends JpaRepository<UserGrantedAuthorityEntity, Long> {}

View File

@ -0,0 +1,9 @@
package app.mealsmadeeasy.api.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByUsername(String username);
}

View File

@ -0,0 +1,10 @@
package app.mealsmadeeasy.api.user;
import java.util.Set;
public interface UserService {
User createUser(String username, String email, String rawPassword, Set<UserGrantedAuthority> authorities);
User updateUser(User user);
void deleteUser(User user);
void deleteUser(String username);
}

View File

@ -0,0 +1,50 @@
package app.mealsmadeeasy.api.user;
import app.mealsmadeeasy.api.security.JpaUserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public final class UserServiceImpl implements UserService {
private final JpaUserDetailsService jpaUserDetailsService;
private final PasswordEncoder passwordEncoder;
public UserServiceImpl(JpaUserDetailsService jpaUserDetailsService, PasswordEncoder passwordEncoder) {
this.jpaUserDetailsService = jpaUserDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Override
public User createUser(
String username,
String email,
String rawPassword,
Set<UserGrantedAuthority> authorities
) {
final UserEntity draft = UserEntity.getDefaultDraft();
draft.setUsername(username);
draft.setEmail(email);
draft.setPassword(this.passwordEncoder.encode(rawPassword));
draft.addAuthorities(authorities);
return this.jpaUserDetailsService.createUser(draft);
}
@Override
public User updateUser(User user) {
return this.jpaUserDetailsService.updateUser(user);
}
@Override
public void deleteUser(User user) {
this.jpaUserDetailsService.deleteUser(user);
}
@Override
public void deleteUser(String username) {
this.jpaUserDetailsService.deleteUser(username);
}
}

View File

@ -1,7 +1,8 @@
spring.application.name=meals-made-easy-api
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.url=jdbc:mysql://localhost:55001/meals_made_easy_api
spring.datasource.username=meals-made-easy-api-user
spring.datasource.password=devpass
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
app.mealsmadeeasy.api.security.token-lifetime=60
app.mealsmadeeasy.api.security.access-token-lifetime=60
app.mealsmadeeasy.api.security.refresh-token-lifetime=120