From dbf34d353f78c3c032202014b77d13a5a211725a Mon Sep 17 00:00:00 2001
From: Daniel Gerhardt <code@dgerhardt.net>
Date: Wed, 28 Feb 2018 13:49:58 +0100
Subject: [PATCH] Add support for JWT authentication

---
 pom.xml                                       |  5 ++
 .../jwt/JwtAuthenticationProvider.java        | 27 +++++++
 .../thm/arsnova/security/jwt/JwtService.java  | 76 +++++++++++++++++++
 .../de/thm/arsnova/security/jwt/JwtToken.java | 34 +++++++++
 .../arsnova/security/jwt/JwtTokenFilter.java  | 29 +++++++
 src/main/resources/arsnova.properties.example |  6 ++
 src/test/resources/arsnova.properties.example |  6 ++
 7 files changed, 183 insertions(+)
 create mode 100644 src/main/java/de/thm/arsnova/security/jwt/JwtAuthenticationProvider.java
 create mode 100644 src/main/java/de/thm/arsnova/security/jwt/JwtService.java
 create mode 100644 src/main/java/de/thm/arsnova/security/jwt/JwtToken.java
 create mode 100644 src/main/java/de/thm/arsnova/security/jwt/JwtTokenFilter.java

diff --git a/pom.xml b/pom.xml
index bdb5d2590..50a2777c1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -250,6 +250,11 @@
 			<artifactId>junit</artifactId>
 			<scope>test</scope>
 		</dependency>
+		<dependency>
+			<groupId>com.auth0</groupId>
+			<artifactId>java-jwt</artifactId>
+			<version>3.4.0</version>
+		</dependency>
 		<dependency>
 			<groupId>org.pac4j</groupId>
 			<artifactId>pac4j-oauth</artifactId>
diff --git a/src/main/java/de/thm/arsnova/security/jwt/JwtAuthenticationProvider.java b/src/main/java/de/thm/arsnova/security/jwt/JwtAuthenticationProvider.java
new file mode 100644
index 000000000..ac7d51b46
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/security/jwt/JwtAuthenticationProvider.java
@@ -0,0 +1,27 @@
+package de.thm.arsnova.security.jwt;
+
+import de.thm.arsnova.security.User;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+
+public class JwtAuthenticationProvider implements AuthenticationProvider {
+	private JwtService jwtService;
+
+	public JwtAuthenticationProvider(final JwtService jwtService) {
+		this.jwtService = jwtService;
+	}
+
+	@Override
+	public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
+		final String token = (String) authentication.getCredentials();
+		final User user = jwtService.verifyToken((String) authentication.getCredentials());
+
+		return new JwtToken(token, user, user.getAuthorities());
+	}
+
+	@Override
+	public boolean supports(final Class<?> aClass) {
+		return JwtToken.class.isAssignableFrom(aClass);
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/security/jwt/JwtService.java b/src/main/java/de/thm/arsnova/security/jwt/JwtService.java
new file mode 100644
index 000000000..f1620aaee
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/security/jwt/JwtService.java
@@ -0,0 +1,76 @@
+package de.thm.arsnova.security.jwt;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import de.thm.arsnova.security.User;
+import de.thm.arsnova.services.UserService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.stereotype.Service;
+
+import java.io.UnsupportedEncodingException;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.TemporalAmount;
+import java.util.Collection;
+import java.util.Date;
+import java.util.stream.Collectors;
+
+@Service
+public class JwtService {
+	private static final String CONFIG_PREFIX = "security.jwt.";
+	private static final String ROLE_PREFIX = "ROLE_";
+	private static final String ROLES_CLAIM_NAME = "roles";
+	private Algorithm algorithm;
+	private String serverId;
+	private TemporalAmount validityPeriod;
+	private JWTVerifier verifier;
+	private UserService userService;
+
+	public JwtService(
+			final UserService userService,
+			@Value("${" + CONFIG_PREFIX + "secret}") final String secret,
+			@Value("${" + CONFIG_PREFIX + "serverId}") final String serverId,
+			@Value("${" + CONFIG_PREFIX + "validity-period}") final String validityPeriod)
+			throws UnsupportedEncodingException {
+		this.userService = userService;
+		this.serverId = serverId;
+		try {
+			this.validityPeriod = Duration.parse("P" + validityPeriod);
+		} catch (Exception e) {
+			throw new IllegalArgumentException(validityPeriod, e);
+		}
+		algorithm = Algorithm.HMAC256(secret);
+		verifier = JWT.require(algorithm)
+				.withAudience(serverId)
+				.build();
+	}
+
+	public String createSignedToken(final User user) {
+		String[] roles = user.getAuthorities().stream()
+				.map(ga -> ga.getAuthority())
+				.filter(ga -> ga.startsWith(ROLE_PREFIX))
+				.map(ga -> ga.substring(ROLE_PREFIX.length())).toArray(String[]::new);
+		return JWT.create()
+				.withIssuer(serverId)
+				.withAudience(serverId)
+				.withIssuedAt(new Date())
+				.withExpiresAt(Date.from(LocalDateTime.now().plus(validityPeriod).toInstant(ZoneOffset.UTC)))
+				.withSubject(user.getId())
+				.withArrayClaim(ROLES_CLAIM_NAME, roles)
+				.sign(algorithm);
+	}
+
+	public User verifyToken(final String token) {
+		final DecodedJWT decodedJwt = verifier.verify(token);
+		final String userId = decodedJwt.getSubject();
+		final Collection<GrantedAuthority> authorities = decodedJwt.getClaim(ROLES_CLAIM_NAME).asList(String.class).stream()
+				.map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role)).collect(Collectors.toList());
+
+		return new User(userService.get(userId), authorities);
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/security/jwt/JwtToken.java b/src/main/java/de/thm/arsnova/security/jwt/JwtToken.java
new file mode 100644
index 000000000..f7ce5cea3
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/security/jwt/JwtToken.java
@@ -0,0 +1,34 @@
+package de.thm.arsnova.security.jwt;
+
+import de.thm.arsnova.security.User;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import java.util.Collection;
+import java.util.Collections;
+
+public class JwtToken extends AbstractAuthenticationToken {
+	private String token;
+	private User principal;
+
+	public JwtToken(final String token, final User principal,
+			final Collection<? extends GrantedAuthority> grantedAuthorities) {
+		super(grantedAuthorities);
+		this.token = token;
+		this.principal = principal;
+	}
+
+	public JwtToken(final String token) {
+		this(token, null, Collections.emptyList());
+	}
+
+	@Override
+	public Object getCredentials() {
+		return token;
+	}
+
+	@Override
+	public Object getPrincipal() {
+		return principal;
+	}
+}
diff --git a/src/main/java/de/thm/arsnova/security/jwt/JwtTokenFilter.java b/src/main/java/de/thm/arsnova/security/jwt/JwtTokenFilter.java
new file mode 100644
index 000000000..282a3c16b
--- /dev/null
+++ b/src/main/java/de/thm/arsnova/security/jwt/JwtTokenFilter.java
@@ -0,0 +1,29 @@
+package de.thm.arsnova.security.jwt;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.preauth.PreAuthenticatedCredentialsNotFoundException;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class JwtTokenFilter extends AbstractAuthenticationProcessingFilter {
+	private static final String JWT_HEADER_NAME = "Arsnova-Auth-Token";
+
+	protected JwtTokenFilter() {
+		super(new AntPathRequestMatcher("/**"));
+	}
+
+	@Override
+	public Authentication attemptAuthentication(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse) throws AuthenticationException {
+		String jwtHeader = httpServletRequest.getHeader(JWT_HEADER_NAME);
+		if (jwtHeader == null) {
+			throw new PreAuthenticatedCredentialsNotFoundException("No authentication header present.");
+		}
+		JwtToken token = new JwtToken(jwtHeader);
+
+		return getAuthenticationManager().authenticate(token);
+	}
+}
diff --git a/src/main/resources/arsnova.properties.example b/src/main/resources/arsnova.properties.example
index b1135e4c3..0eda0cadf 100644
--- a/src/main/resources/arsnova.properties.example
+++ b/src/main/resources/arsnova.properties.example
@@ -59,6 +59,12 @@ mail.host=
 # several minutes
 security.authentication.login-try-limit=50
 
+# JSON Web Tokens
+#
+security.jwt.serverId=arsnova.backend.v3:com.example
+security.jwt.secret=
+security.jwt.validity-period=T6H
+
 # Configuration parameters for authentication services:
 # enabled: enable or disable the service
 # allowed-roles: enable/disable service for a specific role (valid roles: speaker, student)
diff --git a/src/test/resources/arsnova.properties.example b/src/test/resources/arsnova.properties.example
index b1135e4c3..0eda0cadf 100644
--- a/src/test/resources/arsnova.properties.example
+++ b/src/test/resources/arsnova.properties.example
@@ -59,6 +59,12 @@ mail.host=
 # several minutes
 security.authentication.login-try-limit=50
 
+# JSON Web Tokens
+#
+security.jwt.serverId=arsnova.backend.v3:com.example
+security.jwt.secret=
+security.jwt.validity-period=T6H
+
 # Configuration parameters for authentication services:
 # enabled: enable or disable the service
 # allowed-roles: enable/disable service for a specific role (valid roles: speaker, student)
-- 
GitLab