diff --git a/build.gradle b/build.gradle index d23270b..5140ea7 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework:spring-websocket:5.2.4.RELEASE' implementation 'io.jsonwebtoken:jjwt:0.9.1' implementation 'org.springframework.security:spring-security-web' implementation 'org.postgresql:postgresql' diff --git a/gradle.yml b/gradle.yml new file mode 100644 index 0000000..51fefa4 --- /dev/null +++ b/gradle.yml @@ -0,0 +1,39 @@ +# vim: set ts=2 sw=2 et tw=80: +image: gradle:jdk13 + +stages: + - build + - test + - deploy + +smarthut_build: + stage: build + script: + - gradle assemble + artifacts: + paths: + - build/libs/*.jar + expire_in: 1 week + +smarthut_test: + stage: test + script: + - gradle check + +smarthut_deploy: + stage: deploy + image: docker:latest + services: + - docker:dind + variables: + DOCKER_DRIVER: overlay + before_script: + - docker version + - docker info + - docker login -u smarthutsm -p $CI_DOCKER_PASS + script: + - "docker build -t smarthutsm/smarthut:${CI_COMMIT_BRANCH} --pull ." + - "docker push smarthutsm/smarthut:${CI_COMMIT_BRANCH}" + after_script: + - docker logout + diff --git a/socket_test.html b/socket_test.html new file mode 100644 index 0000000..90feba2 --- /dev/null +++ b/socket_test.html @@ -0,0 +1,36 @@ + + + + + +
+

Waiting for authentication...

+
+ + + diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTRequestFilter.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTRequestFilter.java index 853083b..e0cbb6a 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTRequestFilter.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTRequestFilter.java @@ -20,7 +20,7 @@ public class JWTRequestFilter extends OncePerRequestFilter { @Autowired private JWTUserDetailsService jwtUserDetailsService; - @Autowired private JWTTokenUtil jwtTokenUtil; + @Autowired private JWTTokenUtils jwtTokenUtils; @Override protected void doFilterInternal( @@ -30,13 +30,11 @@ public class JWTRequestFilter extends OncePerRequestFilter { String username = null; String jwtToken = null; - // JWT Token is in th - // e form "Bearer token". Remove Bearer word and get - // only the Token + // JWT Token is in the form "Bearer token". Remove Bearer word and get only the Token if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) { jwtToken = requestTokenHeader.substring(7); try { - username = jwtTokenUtil.getUsernameFromToken(jwtToken); + username = jwtTokenUtils.getUsernameFromToken(jwtToken); } catch (IllegalArgumentException e) { System.out.println("Unable to get JWT Token"); } catch (ExpiredJwtException e) { @@ -44,14 +42,15 @@ public class JWTRequestFilter extends OncePerRequestFilter { } } else { logger.warn("JWT Token does not begin with Bearer String"); - } // Once we get the token validate it. + } + + // Once we get the token validate it. if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = this.jwtUserDetailsService.loadUserByUsername( username); // if token is valid configure Spring Security to manually - // set - // authentication - if (jwtTokenUtil.validateToken(jwtToken, userDetails)) { + // set authentication + if (jwtTokenUtils.validateToken(jwtToken, userDetails)) { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtil.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtil.java deleted file mode 100644 index 40d369f..0000000 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtil.java +++ /dev/null @@ -1,72 +0,0 @@ -package ch.usi.inf.sa4.sanmarinoes.smarthut.config; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; - -@Component -public class JWTTokenUtil { - public static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60; - - @Value("${jwt.secret}") - private String secret; - - // retrieve username from jwt token - public String getUsernameFromToken(String token) { - return getClaimFromToken(token, Claims::getSubject); - } - - // retrieve expiration date from jwt token - public Date getExpirationDateFromToken(String token) { - return getClaimFromToken(token, Claims::getExpiration); - } - - public T getClaimFromToken(String token, Function claimsResolver) { - final Claims claims = getAllClaimsFromToken(token); - return claimsResolver.apply(claims); - } - - // for retrieveing any information from token we will need the secret key - private Claims getAllClaimsFromToken(String token) { - return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); - } // check if the token has expired - - private Boolean isTokenExpired(String token) { - final Date expiration = getExpirationDateFromToken(token); - return expiration.before(new Date()); - } // generate token for user - - public String generateToken(UserDetails userDetails) { - Map claims = new HashMap<>(); - return doGenerateToken(claims, userDetails.getUsername()); - } - - // while creating the token - - // 1. Define claims of the token, like Issuer, Expiration, Subject, and the ID - // 2. Sign the JWT using the HS512 algorithm and secret key. - // 3. According to JWS Compact - // Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) - // compaction of the JWT to a URL-safe string - private String doGenerateToken(Map claims, String subject) { - return Jwts.builder() - .setClaims(claims) - .setSubject(subject) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000)) - .signWith(SignatureAlgorithm.HS512, secret) - .compact(); - } - - // validate token - public Boolean validateToken(String token, UserDetails userDetails) { - final String username = getUsernameFromToken(token); - return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); - } -} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtils.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtils.java new file mode 100644 index 0000000..f6943a8 --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/JWTTokenUtils.java @@ -0,0 +1,84 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; +import java.util.HashMap; +import java.util.function.Function; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +/** A utility class to handle JWTs */ +@Component +public class JWTTokenUtils { + /** The duration in seconds of the validity of a single token */ + private static final long JWT_TOKEN_VALIDITY = 5 * 60 * 60; + + /** The secret key used to encrypt all JWTs */ + @Value("${jwt.secret}") + private String secret; + + /** + * Retrieves the claimed username from a given token + * + * @param token the token to inspect + * @return the username + */ + public String getUsernameFromToken(String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + /** + * Returns whether the token given is expired or not + * + * @param token the given token + * @return true if expired, false if not + */ + public Boolean isTokenExpired(String token) { + final Date expiration = getClaimFromToken(token, Claims::getExpiration); + return expiration.before(new Date()); + } + + /** + * Creates a new JWT for a given user. While creating the token - 1. Define claims of the token, + * like Issuer, Expiration, Subject, and the ID 2. Sign the JWT using the HS512 algorithm and + * secret key. 3. According to JWS Compact Serialization + * (https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) compaction of + * the JWT to a URL-safe string + * + * @param user the user to which create a JWT + * @return the newly generated token + */ + public String generateToken(UserDetails user) { + return Jwts.builder() + .setClaims(new HashMap<>()) + .setSubject(user.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000)) + .signWith(SignatureAlgorithm.HS512, secret) + .compact(); + } + + /** + * Validates the token given against matching userDetails + * + * @param token the token given + * @param userDetails user details to validate against + * @return true if valid, false if not + */ + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = getUsernameFromToken(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } + + private T getClaimFromToken(String token, Function claimsResolver) { + final Claims claims = getAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + private Claims getAllClaimsFromToken(String token) { + return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java index 253998d..ec116c3 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/config/WebSecurityConfig.java @@ -51,6 +51,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { // dont authenticate this particular request .authorizeRequests() .antMatchers( + "/sensor-socket", "/auth/login", "/swagger-ui.html", "/register", diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/AuthenticationController.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/AuthenticationController.java index 1a1e266..3160e1c 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/AuthenticationController.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/controller/AuthenticationController.java @@ -1,6 +1,6 @@ package ch.usi.inf.sa4.sanmarinoes.smarthut.controller; -import ch.usi.inf.sa4.sanmarinoes.smarthut.config.JWTTokenUtil; +import ch.usi.inf.sa4.sanmarinoes.smarthut.config.JWTTokenUtils; import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.JWTRequest; import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.JWTResponse; import ch.usi.inf.sa4.sanmarinoes.smarthut.dto.UserUpdateRequest; @@ -26,7 +26,7 @@ public class AuthenticationController { private final UserRepository userRepository; - private final JWTTokenUtil jwtTokenUtil; + private final JWTTokenUtils jwtTokenUtils; private final JWTUserDetailsService userDetailsService; @@ -35,11 +35,11 @@ public class AuthenticationController { public AuthenticationController( AuthenticationManager authenticationManager, UserRepository userRepository, - JWTTokenUtil jwtTokenUtil, + JWTTokenUtils jwtTokenUtils, JWTUserDetailsService userDetailsService) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; - this.jwtTokenUtil = jwtTokenUtil; + this.jwtTokenUtils = jwtTokenUtils; this.userDetailsService = userDetailsService; } @@ -68,7 +68,7 @@ public class AuthenticationController { authenticationRequest.getUsernameOrEmail()); } - final String token = jwtTokenUtil.generateToken(userDetails); + final String token = jwtTokenUtils.generateToken(userDetails); return new JWTResponse(token); } diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/AuthenticationMessageListener.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/AuthenticationMessageListener.java new file mode 100644 index 0000000..e0b9249 --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/AuthenticationMessageListener.java @@ -0,0 +1,96 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.socket; + +import ch.usi.inf.sa4.sanmarinoes.smarthut.config.JWTTokenUtils; +import ch.usi.inf.sa4.sanmarinoes.smarthut.models.User; +import ch.usi.inf.sa4.sanmarinoes.smarthut.models.UserRepository; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.jsonwebtoken.ExpiredJwtException; +import java.io.IOException; +import java.util.Map; +import java.util.function.BiConsumer; +import javax.websocket.MessageHandler; +import javax.websocket.Session; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** Generates MessageHandlers for unauthenticated socket sessions */ +@Component +public class AuthenticationMessageListener { + + private Gson gson = new Gson(); + + private JWTTokenUtils jwtTokenUtils; + + private UserRepository userRepository; + + @Autowired + public AuthenticationMessageListener( + JWTTokenUtils jwtTokenUtils, UserRepository userRepository) { + this.jwtTokenUtils = jwtTokenUtils; + this.userRepository = userRepository; + } + + /** + * Generates a new message handler to handle socket authentication + * + * @param session the session to which authentication must be checked + * @param authorizedSetter function to call once user is authenticated + * @return a new message handler to handle socket authentication + */ + MessageHandler.Whole newHandler( + final Session session, BiConsumer authorizedSetter) { + return new MessageHandler.Whole<>() { + @Override + public void onMessage(final String message) { + System.out.println(message); + + if (message == null) { + acknowledge(false); + return; + } + + String token; + String username; + + try { + token = gson.fromJson(message, JsonObject.class).get("token").getAsString(); + username = jwtTokenUtils.getUsernameFromToken(token); + } catch (ExpiredJwtException e) { + System.err.println(e.getMessage()); + acknowledge(false); + return; + } catch (Throwable ignored) { + System.out.println("Token format not valid"); + acknowledge(false); + return; + } + + final User user = userRepository.findByUsername(username); + if (user == null || jwtTokenUtils.isTokenExpired(token)) { + System.out.println("Token not valid"); + acknowledge(false); + return; + } + + // Here user is authenticated + session.removeMessageHandler(this); + + // Add user-session pair in authorized list + authorizedSetter.accept(user, session); + + // update client to acknowledge authentication + acknowledge(true); + } + + private void acknowledge(boolean success) { + try { + session.getBasicRemote() + .sendText(gson.toJson(Map.of("authenticated", success))); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketConfig.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketConfig.java new file mode 100644 index 0000000..503667a --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketConfig.java @@ -0,0 +1,54 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.socket; + +import javax.websocket.server.ServerEndpointConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.server.standard.ServerEndpointExporter; +import org.springframework.web.socket.server.standard.ServerEndpointRegistration; + +/** Configures the sensor socket and maps it to the /sensor-socket path */ +@Configuration +public class SensorSocketConfig extends ServerEndpointConfig.Configurator { + + private SensorSocketEndpoint instance; + + @Autowired + public SensorSocketConfig(SensorSocketEndpoint instance) { + this.instance = instance; + } + + /** + * Registers the sensor socket endpoint to the url /sensor-socket + * + * @return an endpoint registration object + */ + @Bean + public ServerEndpointRegistration serverEndpointRegistration() { + return new ServerEndpointRegistration("/sensor-socket", instance); + } + + /** + * Returns a new ServerEndpointExporter + * + * @return a new ServerEndpointExporter + */ + @Bean + public ServerEndpointExporter endpointExporter() { + return new ServerEndpointExporter(); + } + + @Override + public T getEndpointInstance(Class endpointClass) throws InstantiationException { + try { + @SuppressWarnings("unchecked") + final T instance = (T) this.instance; + return instance; + } catch (ClassCastException e) { + final var e2 = + new InstantiationException("Cannot cast SensorSocketEndpoint to desired type"); + e2.initCause(e); + throw e2; + } + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketEndpoint.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketEndpoint.java new file mode 100644 index 0000000..d40e874 --- /dev/null +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/socket/SensorSocketEndpoint.java @@ -0,0 +1,84 @@ +package ch.usi.inf.sa4.sanmarinoes.smarthut.socket; + +import static ch.usi.inf.sa4.sanmarinoes.smarthut.utils.Utils.didThrow; + +import ch.usi.inf.sa4.sanmarinoes.smarthut.models.User; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.google.gson.Gson; +import java.util.*; +import javax.websocket.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** Endpoint of socket at URL /sensor-socket used to update the client with sensor information */ +@Component +public class SensorSocketEndpoint extends Endpoint { + + private Gson gson = new Gson(); + + private AuthenticationMessageListener authenticationMessageListener; + + private Set unauthorizedClients = Collections.synchronizedSet(new HashSet<>()); + + private Multimap authorizedClients = + Multimaps.synchronizedMultimap(HashMultimap.create()); + + @Autowired + public SensorSocketEndpoint(AuthenticationMessageListener authenticationMessageListener) { + this.authenticationMessageListener = authenticationMessageListener; + } + + /** + * Returns a synchronized set of socket sessions not yet authorized with a token + * + * @return a synchronized set of socket sessions not yet authorized with a token + */ + public Set getUnauthorizedClients() { + return unauthorizedClients; + } + + /** + * Returns a synchronized User to Session multimap with authorized sessions + * + * @return a synchronized User to Session multimap with authorized sessions + */ + public Multimap getAuthorizedClients() { + return authorizedClients; + } + + /** + * Given a message and a user, broadcasts that message in json to all associated clients and + * returns the number of successful transfers + * + * @param message the message to send + * @param u the user to which to send the message + * @return number of successful transfer + */ + public long broadcast(Object message, User u) { + final Collection sessions = authorizedClients.get(u); + return sessions.stream() + .parallel() + .filter(didThrow(s -> s.getAsyncRemote().sendObject(gson.toJson(message)))) + .count(); + } + + /** + * Handles the opening of a socket session with a client + * + * @param session the newly born session + * @param config endpoint configuration + */ + @Override + public void onOpen(Session session, EndpointConfig config) { + unauthorizedClients.add(session); + session.addMessageHandler( + authenticationMessageListener.newHandler( + session, + (u, s) -> { + unauthorizedClients.remove(s); + authorizedClients.put(u, s); + })); + } +} diff --git a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/utils/Utils.java b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/utils/Utils.java index d9fcf12..bc8719d 100644 --- a/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/utils/Utils.java +++ b/src/main/java/ch/usi/inf/sa4/sanmarinoes/smarthut/utils/Utils.java @@ -1,14 +1,29 @@ package ch.usi.inf.sa4.sanmarinoes.smarthut.utils; import java.util.List; +import java.util.concurrent.Future; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.StreamSupport; /** A class with a bunch of useful static methods */ -public class Utils { +public final class Utils { private Utils() {} public static List toList(Iterable iterable) { return StreamSupport.stream(iterable.spliterator(), false).collect(Collectors.toList()); } + + public static Predicate didThrow(Function> consumer) { + return (t) -> { + try { + consumer.apply(t).get(); + return true; + } catch (Throwable e) { + System.err.println(e.getMessage()); + return false; + } + }; + } }