diff --git a/api/build.gradle b/api/build.gradle index ca8b55c..5f139d6 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -27,7 +27,7 @@ jar { description = "twigs-server" } -mainClassName = "com.wbrawner.budgetserver.BudgetServerApplication" +mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication" sourceCompatibility = 14 targetCompatibility = 14 diff --git a/api/src/main/java/com/wbrawner/budgetserver/BudgetServerApplication.java b/api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java similarity index 55% rename from api/src/main/java/com/wbrawner/budgetserver/BudgetServerApplication.java rename to api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java index 08b4d66..241ebf3 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/BudgetServerApplication.java +++ b/api/src/main/java/com/wbrawner/budgetserver/TwigsServerApplication.java @@ -2,10 +2,12 @@ package com.wbrawner.budgetserver; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -public class BudgetServerApplication { +@EnableScheduling +public class TwigsServerApplication { public static void main(String[] args) { - SpringApplication.run(BudgetServerApplication.class, args); + SpringApplication.run(TwigsServerApplication.class, args); } } diff --git a/api/src/main/java/com/wbrawner/budgetserver/Utils.java b/api/src/main/java/com/wbrawner/budgetserver/Utils.java index 2e1bc7c..907263c 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/Utils.java +++ b/api/src/main/java/com/wbrawner/budgetserver/Utils.java @@ -3,10 +3,11 @@ package com.wbrawner.budgetserver; import com.wbrawner.budgetserver.user.User; import org.springframework.security.core.context.SecurityContextHolder; +import java.security.SecureRandom; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; -import java.util.UUID; +import java.util.Random; public final class Utils { private static final int[] CALENDAR_FIELDS = new int[]{ @@ -33,6 +34,12 @@ public final class Utils { return calendar.getTime(); } + public static Date twoWeeksFromNow() { + GregorianCalendar calendar = new GregorianCalendar(); + calendar.add(Calendar.DATE, 14); + return calendar.getTime(); + } + public static User getCurrentUser() { Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (user instanceof User) { @@ -42,7 +49,18 @@ public final class Utils { return null; } + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final Random random = new SecureRandom(); + + public static String randomString(int length) { + StringBuilder id = new StringBuilder(); + for (int i = 0; i < length; i++) { + id.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length()))); + } + return id.toString(); + } + public static String randomId() { - return UUID.randomUUID().toString().replace("-", ""); + return randomString(32); } } diff --git a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java index 7577379..a1c5460 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java +++ b/api/src/main/java/com/wbrawner/budgetserver/budget/BudgetRepository.java @@ -2,5 +2,5 @@ package com.wbrawner.budgetserver.budget; import org.springframework.data.repository.PagingAndSortingRepository; -public interface BudgetRepository extends PagingAndSortingRepository { +public interface BudgetRepository extends PagingAndSortingRepository { } \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java b/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java index ddafeee..287355a 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java +++ b/api/src/main/java/com/wbrawner/budgetserver/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.wbrawner.budgetserver.config; import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository; +import com.wbrawner.budgetserver.session.UserSessionRepository; import com.wbrawner.budgetserver.user.UserRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,6 +12,7 @@ import org.springframework.security.config.annotation.authentication.builders.Au 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.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.JdbcUserDetailsManager; @@ -28,6 +30,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { private final Environment env; private final DataSource datasource; + private final UserSessionRepository userSessionRepository; private final UserRepository userRepository; private final PasswordResetRequestRepository passwordResetRequestRepository; private final JdbcUserDetailsService userDetailsService; @@ -35,12 +38,14 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { public SecurityConfig(Environment env, DataSource datasource, + UserSessionRepository userSessionRepository, UserRepository userRepository, PasswordResetRequestRepository passwordResetRequestRepository, JdbcUserDetailsService userDetailsService, Environment environment) { this.env = env; this.datasource = datasource; + this.userSessionRepository = userSessionRepository; this.userRepository = userRepository; this.passwordResetRequestRepository = passwordResetRequestRepository; this.userDetailsService = userDetailsService; @@ -56,7 +61,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public DaoAuthenticationProvider getAuthenticationProvider() { - var authProvider = new DaoAuthenticationProvider(); + var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository); authProvider.setPasswordEncoder(getPasswordEncoder()); authProvider.setUserDetailsService(userDetailsService); return authProvider; @@ -81,6 +86,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { .authenticated() .and() .httpBasic() + .authenticationEntryPoint(new SilentAuthenticationEntryPoint()) .and() .cors() .configurationSource(request -> { @@ -104,7 +110,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { }) .and() .csrf() - .disable(); + .disable() + .addFilter(new TokenAuthenticationFilter(authenticationManager())) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } } diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java b/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java new file mode 100644 index 0000000..59cea2e --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/config/SessionAuthenticationToken.java @@ -0,0 +1,20 @@ +package com.wbrawner.budgetserver.config; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class SessionAuthenticationToken extends UsernamePasswordAuthenticationToken { + /** + * Creates a token with the supplied array of authorities. + * + * @param authorities the collection of GrantedAuthoritys for the principal + * represented by this authentication object. + * @param credentials + * @param principal + */ + public SessionAuthenticationToken(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java b/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java new file mode 100644 index 0000000..d556b9e --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/config/SilentAuthenticationEntryPoint.java @@ -0,0 +1,19 @@ +package com.wbrawner.budgetserver.config; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Used to avoid browser prompts for authentication + */ +public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java new file mode 100644 index 0000000..1e449dc --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationFilter.java @@ -0,0 +1,32 @@ +package com.wbrawner.budgetserver.config; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + +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.Collections; + +public class TokenAuthenticationFilter extends BasicAuthenticationFilter { + + public TokenAuthenticationFilter(AuthenticationManager authenticationManager) { + super(authenticationManager); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + var authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + chain.doFilter(request, response); + return; + } + var token = authHeader.substring(7); + var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(null, token, Collections.emptyList())); + SecurityContextHolder.getContext().setAuthentication(authentication); + chain.doFilter(request, response); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java new file mode 100644 index 0000000..98dfa95 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/config/TokenAuthenticationProvider.java @@ -0,0 +1,48 @@ +package com.wbrawner.budgetserver.config; + +import com.wbrawner.budgetserver.session.UserSessionRepository; +import com.wbrawner.budgetserver.user.UserRepository; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Date; + +public class TokenAuthenticationProvider extends DaoAuthenticationProvider { + private final UserSessionRepository userSessionRepository; + private final UserRepository userRepository; + + public TokenAuthenticationProvider(UserSessionRepository userSessionRepository, UserRepository userRepository) { + this.userSessionRepository = userSessionRepository; + this.userRepository = userRepository; + } + + @Override + protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { + if (!(authentication instanceof SessionAuthenticationToken)) { + // Additional checks aren't needed since they've already been handled + super.additionalAuthenticationChecks(userDetails, authentication); + } + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication instanceof SessionAuthenticationToken) { + var session = userSessionRepository.findByToken((String) authentication.getCredentials()); + if (session.isEmpty() || session.get().getExpiration().before(new Date())) { + throw new BadCredentialsException("Credentials expired"); + } + var user = userRepository.findById(session.get().getUserId()); + if (user.isEmpty()) { + throw new InternalAuthenticationServiceException("Failed to find user for token"); + } + return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities()); + } else { + return super.authenticate(authentication); + } + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/Session.java b/api/src/main/java/com/wbrawner/budgetserver/session/Session.java new file mode 100644 index 0000000..487e5a5 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/session/Session.java @@ -0,0 +1,44 @@ +package com.wbrawner.budgetserver.session; + +import javax.persistence.Entity; +import javax.persistence.Id; +import java.util.Date; + +import static com.wbrawner.budgetserver.Utils.*; + +@Entity +public class Session { + @Id + private final String id = randomId(); + private final String userId; + private final String token = randomString(255); + private Date expiration = twoWeeksFromNow(); + + public Session() { + this(""); + } + + public Session(String userId) { + this.userId = userId; + } + + public String getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getToken() { + return token; + } + + public Date getExpiration() { + return expiration; + } + + public void setExpiration(Date expiration) { + this.expiration = expiration; + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java b/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java new file mode 100644 index 0000000..d077984 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/session/SessionCleanupTask.java @@ -0,0 +1,22 @@ +package com.wbrawner.budgetserver.session; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +public class SessionCleanupTask { + private final UserSessionRepository sessionRepository; + + @Autowired + public SessionCleanupTask(UserSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + @Scheduled(cron = "0 0 * * * *") + public void cleanup() { + sessionRepository.deleteAllByExpirationBefore(new Date()); + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java b/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java new file mode 100644 index 0000000..bfacf4d --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/session/SessionResponse.java @@ -0,0 +1,25 @@ +package com.wbrawner.budgetserver.session; + +import java.util.Date; + +public class SessionResponse { + private final String token; + private final String expiration; + + public SessionResponse(Session session) { + this(session.getToken(), session.getExpiration()); + } + + public SessionResponse(String token, Date expiration) { + this.token = token; + this.expiration = expiration.toInstant().toString(); + } + + public String getToken() { + return token; + } + + public String getExpiration() { + return expiration; + } +} diff --git a/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java b/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java new file mode 100644 index 0000000..19c58c9 --- /dev/null +++ b/api/src/main/java/com/wbrawner/budgetserver/session/UserSessionRepository.java @@ -0,0 +1,17 @@ +package com.wbrawner.budgetserver.session; + +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.util.Date; +import java.util.List; +import java.util.Optional; + +public interface UserSessionRepository extends PagingAndSortingRepository { + List findByUserId(String userId); + + Optional findByToken(String token); + + Optional findByUserIdAndToken(String userId, String token); + + void deleteAllByExpirationBefore(Date expiration); +} \ No newline at end of file diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/User.java b/api/src/main/java/com/wbrawner/budgetserver/user/User.java index 56cca3c..eedae7c 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/user/User.java +++ b/api/src/main/java/com/wbrawner/budgetserver/user/User.java @@ -1,10 +1,13 @@ package com.wbrawner.budgetserver.user; +import org.springframework.lang.NonNull; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import javax.persistence.*; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Transient; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -31,6 +34,7 @@ public class User implements UserDetails { this.email = email; } + @NonNull public String getId() { // This shouldn't ever need to be set manually, only through Hibernate //noinspection ConstantConditions diff --git a/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java b/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java index 465051d..decbde5 100644 --- a/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java +++ b/api/src/main/java/com/wbrawner/budgetserver/user/UserController.java @@ -4,6 +4,9 @@ import com.wbrawner.budgetserver.ErrorResponse; import com.wbrawner.budgetserver.budget.BudgetRepository; import com.wbrawner.budgetserver.permission.UserPermissionRepository; import com.wbrawner.budgetserver.permission.UserPermissionResponse; +import com.wbrawner.budgetserver.session.Session; +import com.wbrawner.budgetserver.session.SessionResponse; +import com.wbrawner.budgetserver.session.UserSessionRepository; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.Authorization; @@ -20,6 +23,7 @@ import org.springframework.web.bind.annotation.*; import javax.transaction.Transactional; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import static com.wbrawner.budgetserver.Utils.getCurrentUser; @@ -33,16 +37,19 @@ public class UserController { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final UserPermissionRepository userPermissionsRepository; + private final UserSessionRepository userSessionRepository; private final DaoAuthenticationProvider authenticationProvider; @Autowired public UserController(BudgetRepository budgetRepository, UserRepository userRepository, + UserSessionRepository userSessionRepository, PasswordEncoder passwordEncoder, UserPermissionRepository userPermissionsRepository, DaoAuthenticationProvider authenticationProvider) { this.budgetRepository = budgetRepository; this.userRepository = userRepository; + this.userSessionRepository = userSessionRepository; this.passwordEncoder = passwordEncoder; this.userPermissionsRepository = userPermissionsRepository; this.authenticationProvider = authenticationProvider; @@ -51,7 +58,7 @@ public class UserController { @GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE}) @ApiOperation(value = "getUsers", nickname = "getUsers", tags = {"Users"}) - ResponseEntity> getUsers(Long budgetId) { + ResponseEntity> getUsers(String budgetId) { var budget = budgetRepository.findById(budgetId).orElse(null); if (budget == null) { return ResponseEntity.notFound().build(); @@ -69,7 +76,7 @@ public class UserController { @PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE}) @ApiOperation(value = "login", nickname = "login", tags = {"Users"}) - ResponseEntity login(@RequestBody LoginRequest request) { + ResponseEntity login(@RequestBody LoginRequest request) { var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication auth; try { @@ -78,7 +85,9 @@ public class UserController { return ResponseEntity.notFound().build(); } SecurityContextHolder.getContext().setAuthentication(auth); - return ResponseEntity.ok(new UserResponse(getCurrentUser())); + var user = Objects.requireNonNull(getCurrentUser()); + var session = userSessionRepository.save(new Session(user.getId())); + return ResponseEntity.ok(new SessionResponse(session)); } @GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE})