Compare commits

3 Commits

Author SHA1 Message Date
50157ed4e7 End security setup. 2020-07-19 19:29:13 +02:00
36089cacfa End security setup. 2020-07-19 17:00:04 +02:00
5e202b122e Init api and security. 2020-07-16 14:06:41 +02:00
23 changed files with 674 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

87
pom.xml Normal file
View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.takiguchi</groupId>
<artifactId>starter</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>starter</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package org.takiguchi.starter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class StarterApplication {
public static void main(String[] args) {
SpringApplication.run(StarterApplication.class, args);
}
}

View File

@@ -0,0 +1,70 @@
package org.takiguchi.starter.config.security;
import io.jsonwebtoken.Claims;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.takiguchi.starter.config.security.TokenProvider.AUTHORITIES_KEY;
public class JwtRequestFilter extends OncePerRequestFilter {
private static final String TOKEN_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
public JwtRequestFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String token = getTokenFromHeaders(request);
// Once we get the token validate it.
if (SecurityContextHolder.getContext().getAuthentication() == null) {
String username = null;
try {
username = tokenProvider.getUserEmailFromToken(token);
} catch (Exception e) {
// Do nothing
}
if (username != null && !tokenProvider.isTokenExpired(token)) {
Claims claims = tokenProvider.getAllClaimsFromToken(token);
List<String> roles = claims.get(AUTHORITIES_KEY, List.class);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, username, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
private String getTokenFromHeaders(HttpServletRequest request) {
String token = null;
String authHeader = request.getHeader(AUTHORIZATION);
if (authHeader != null && authHeader.startsWith(TOKEN_PREFIX)) {
token = authHeader.replace(TOKEN_PREFIX, "");
} else {
logger.debug("Couldn't find bearer string, will ignore the header.");
}
return token;
}
}

View File

@@ -0,0 +1,28 @@
package org.takiguchi.starter.config.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@EnableWebSecurity
@Configuration
public class SecurityConfiguration {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public TokenProvider tokenProvider(@Value("${app.security.signing-key}") String signingKey,
@Value("${app.security.access-token-validity-seconds}") int accessTokenValiditySeconds) {
return new TokenProvider(signingKey, accessTokenValiditySeconds);
}
@Bean
public JwtRequestFilter jwtRequestFilter(TokenProvider tokenProvider) {
return new JwtRequestFilter(tokenProvider);
}
}

View File

@@ -0,0 +1,48 @@
package org.takiguchi.starter.config.security;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletResponse;
import static org.springframework.http.HttpMethod.OPTIONS;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
@EnableWebSecurity
@Configuration
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final JwtRequestFilter jwtRequestFilter;
public SpringSecurityConfiguration(JwtRequestFilter jwtRequestFilter) {
this.jwtRequestFilter = jwtRequestFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authResponse) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(HttpServletResponse.SC_FORBIDDEN))
.and()
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(STATELESS)
.and()
// To force https
// .requiresChannel()
// .anyRequest()
// .requiresSecure()
// .and()
.csrf().disable()
.authorizeRequests()
.antMatchers(
"/api/auth/login",
"/api/health/check"
).permitAll()
.antMatchers(OPTIONS).permitAll()
.anyRequest().authenticated();
}
}

View File

@@ -0,0 +1,70 @@
package org.takiguchi.starter.config.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.takiguchi.starter.model.dao.User;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.function.Function;
public class TokenProvider {
public static final String AUTHORITIES_KEY = "scopes";
private final String signingKey;
private final int accessTokenValiditySeconds;
public TokenProvider(String signingKey, int accessTokenValiditySeconds) {
this.signingKey = signingKey;
this.accessTokenValiditySeconds = accessTokenValiditySeconds;
}
public String getUserEmailFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}
public Date getExpirationDateFromToken(String token) {
return getClaimFromToken(token, Claims::getExpiration);
}
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}
public Boolean isTokenExpired(String token) {
final Date expiration = getExpirationDateFromToken(token);
return expiration.before(new Date());
}
public Claims getAllClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(signingKey)
.parseClaimsJws(token)
.getBody();
}
public String generateToken(User user) {
return Jwts.builder()
.setSubject(user.getEmail())
.claim(AUTHORITIES_KEY, getAuthorities(user))
.signWith(SignatureAlgorithm.HS256, signingKey)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + accessTokenValiditySeconds * 1000))
.compact();
}
private List<String> getAuthorities(User user) {
List<String> authorities = Collections.emptyList();
// if (!CollectionUtils.isEmpty(user.getRoles())) {
// authorities = user.getRoles().stream()
// .map(Role::getName)
// .collect(Collectors.toList());
// }
return authorities;
}
}

View File

@@ -0,0 +1,47 @@
package org.takiguchi.starter.controller;
import org.springframework.security.crypto.password.PasswordEncoder;
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;
import org.takiguchi.starter.config.security.TokenProvider;
import org.takiguchi.starter.exception.BadRequestException;
import org.takiguchi.starter.model.dao.User;
import org.takiguchi.starter.model.dto.LoginRequest;
import org.takiguchi.starter.model.dto.LoginResponse;
import org.takiguchi.starter.repository.UserRepository;
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
public AuthenticationController(UserRepository userRepository, PasswordEncoder passwordEncoder, TokenProvider tokenProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.tokenProvider = tokenProvider;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
return userRepository.findByEmail(request.getEmail())
.map(user -> checkCredentials(request, user))
.map(LoginResponse::new)
.orElseThrow(() -> new BadRequestException("MSG_INVALID_CREDENTIALS"));
}
/**
* If passwords match, this function generate a token. Otherwise, an {@link BadRequestException} is returned.
*/
private String checkCredentials(LoginRequest loginRequest, User user) {
if (passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
return tokenProvider.generateToken(user);
} else {
throw new BadRequestException("Invalid credentials");
}
}
}

View File

@@ -0,0 +1,15 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
public class BadRequestException extends BusinessException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,26 @@
package org.takiguchi.starter.exception;
/**
* Business exception.
*/
public class BusinessException extends RuntimeException {
public BusinessException() {}
/**
* Constructs an exception with a message.
* @param message The description of the error met.
*/
public BusinessException(final String message) {
super(message);
}
/**
* Constructs an exception with a message and a code.
* @param message The description of the error met.
* @param cause The cause of the exception.
*/
public BusinessException(final String message, final Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,23 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Exception thrown when user attempt to access a resource that he has not rights.
*/
@ResponseStatus(value = HttpStatus.FORBIDDEN)
public class ForbiddenException extends BusinessException {
public ForbiddenException() {
super();
}
public ForbiddenException(String message) {
super(message);
}
public ForbiddenException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,15 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public class InternalServerErrorException extends TechnicalException {
public InternalServerErrorException(String message) {
super(message);
}
public InternalServerErrorException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,17 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public class NoContentException extends BusinessException {
public NoContentException() {}
public NoContentException(String message) {
super(message);
}
public NoContentException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,17 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NotFoundException extends BusinessException {
public NotFoundException() {}
public NotFoundException(String message) {
super(message);
}
public NotFoundException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,23 @@
package org.takiguchi.starter.exception;
/**
* Technical exception.
*/
public class TechnicalException extends RuntimeException {
/**
* Constructs an exception with a message.
* @param message The description of the error met.
*/
public TechnicalException(final String message) {
super(message);
}
/**
* Constructs an exception with a message and a code.
* @param message The description of the error met.
* @param cause The cause of the exception.
*/
public TechnicalException(final String message, final Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,7 @@
package org.takiguchi.starter.exception;
public class TournamentValidationException extends BusinessException {
public TournamentValidationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,22 @@
package org.takiguchi.starter.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Exception thrown when an anonymous user attempt to access to secured resource or if he failed to login.
*/
@ResponseStatus(value = HttpStatus.UNAUTHORIZED)
public class UnauthorizedException extends BusinessException {
public UnauthorizedException() {
super();
}
public UnauthorizedException(String message) {
super(message);
}
public UnauthorizedException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,25 @@
package org.takiguchi.starter.model.dao;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "`user`")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class User {
@Id
@GeneratedValue(generator = "system-uuid")
private UUID id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
}

View File

@@ -0,0 +1,14 @@
package org.takiguchi.starter.model.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String email;
private String password;
}

View File

@@ -0,0 +1,14 @@
package org.takiguchi.starter.model.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
}

View File

@@ -0,0 +1,13 @@
package org.takiguchi.starter.repository;
import org.takiguchi.starter.model.dao.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
}

View File

@@ -0,0 +1,36 @@
app:
security:
signing-key: SigningKeyValue!
# 5 * 60 * 60 -> 5 hours
access-token-validity-seconds: 18000
# =================================================
# Spring configuration
# =================================================
spring:
# -------------------------------------------------
# Database configuration
# -------------------------------------------------
datasource:
driverClassName: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/db_database
username: postgres
password: postgres
# Disable feature detection by this undocumented parameter.
# Check the org.hibernate.engine.jdbc.internal.JdbcServiceImpl.configure method for more details.
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
properties.hibernate.temp.use_jdbc_metadata_defaults: false
open-in-view: false
server:
error:
whitelabel:
enabled: false # Disable html error responses.
logging:
level:
org:
# hibernate: DEBUG
springframework:
# mail: DEBUG

View File

@@ -0,0 +1,13 @@
package org.takiguchi.starter;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class StarterApplicationTests {
@Test
void contextLoads() {
}
}