Skip to content

Commit

Permalink
Merge pull request #9 from isel-leic-daw/feature/main/gh-2-add-suppor…
Browse files Browse the repository at this point in the history
…t-for-multiple-tokens-and-rolling-lifetime

gh-2: add support for multiple tokens and rolling lifetimes
  • Loading branch information
pmhsfelix authored Oct 20, 2022
2 parents 3069500 + 4c9fc89 commit 2e77425
Show file tree
Hide file tree
Showing 9 changed files with 311 additions and 29 deletions.
4 changes: 3 additions & 1 deletion code/tic-tac-tow-service/sql/create-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ create table dbo.Users(

create table dbo.Tokens(
token_validation VARCHAR(256) primary key,
user_id int references dbo.Users(id)
user_id int references dbo.Users(id),
created_at bigint not null,
last_used_at bigint not null
);

create table dbo.Games(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import pt.isel.daw.tictactow.http.pipeline.AuthenticationInterceptor
import pt.isel.daw.tictactow.http.pipeline.UserArgumentResolver
import pt.isel.daw.tictactow.repository.jdbi.configure
import pt.isel.daw.tictactow.utils.Sha256TokenEncoder
import java.time.Instant

@SpringBootApplication
class TicTacTowApplication {
Expand All @@ -29,6 +30,11 @@ class TicTacTowApplication {

@Bean
fun tokenEncoder() = Sha256TokenEncoder()

@Bean
fun clock() = object : Clock {
override fun now() = Instant.now()
}
}

// QUESTION: why cannot this be in TicTacTowApplication
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package pt.isel.daw.tictactow.domain

import java.time.Instant

class Token(
val tokenValidationInfo: TokenValidationInfo,
val userId: Int,
val createdAt: Instant,
val lastUsedAt: Instant
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package pt.isel.daw.tictactow.repository

import pt.isel.daw.tictactow.domain.PasswordValidationInfo
import pt.isel.daw.tictactow.domain.Token
import pt.isel.daw.tictactow.domain.TokenValidationInfo
import pt.isel.daw.tictactow.domain.User
import java.time.Instant

interface UsersRepository {

Expand All @@ -13,9 +15,11 @@ interface UsersRepository {

fun getUserByUsername(username: String): User?

fun getUserByTokenValidationInfo(tokenValidationInfo: TokenValidationInfo): User?
fun getTokenByTokenValidationInfo(tokenValidationInfo: TokenValidationInfo): Pair<User, Token>?

fun isUserStoredByUsername(username: String): Boolean

fun createToken(userId: Int, token: TokenValidationInfo)
fun createToken(token: Token, maxTokens: Int)

fun updateTokenLastUsed(token: Token, now: Instant)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package pt.isel.daw.tictactow.repository.jdbi

import org.jdbi.v3.core.Handle
import org.jdbi.v3.core.kotlin.mapTo
import org.slf4j.LoggerFactory
import pt.isel.daw.tictactow.domain.PasswordValidationInfo
import pt.isel.daw.tictactow.domain.Token
import pt.isel.daw.tictactow.domain.TokenValidationInfo
import pt.isel.daw.tictactow.domain.User
import pt.isel.daw.tictactow.repository.UsersRepository
import java.time.Instant

class JdbiUsersRepository(
private val handle: Handle
Expand Down Expand Up @@ -36,24 +39,80 @@ class JdbiUsersRepository(
.mapTo<Int>()
.single() == 1

override fun createToken(userId: Int, token: TokenValidationInfo) {
handle.createUpdate("insert into dbo.Tokens(user_id, token_validation) values (:user_id, :token_validation)")
.bind("user_id", userId)
.bind("token_validation", token.validationInfo)
override fun createToken(token: Token, maxTokens: Int) {
val deletions = handle.createUpdate(
"""
delete from dbo.Tokens
where user_id = :user_id
and token_validation in (
select token_validation from dbo.Tokens where user_id = :user_id
order by last_used_at desc offset :offset
)
""".trimIndent()
)
.bind("user_id", token.userId)
.bind("offset", maxTokens - 1)
.execute()

logger.info("{} tokens deleted when creating new token", deletions)

handle.createUpdate(
"""
insert into dbo.Tokens(user_id, token_validation, created_at, last_used_at)
values (:user_id, :token_validation, :created_at, :last_used_at)
""".trimIndent()
)
.bind("user_id", token.userId)
.bind("token_validation", token.tokenValidationInfo.validationInfo)
.bind("created_at", token.createdAt.epochSecond)
.bind("last_used_at", token.lastUsedAt.epochSecond)
.execute()
}

override fun getUserByTokenValidationInfo(tokenValidationInfo: TokenValidationInfo): User? =
override fun updateTokenLastUsed(token: Token, now: Instant) {
handle.createUpdate(
"""
update dbo.Tokens
set last_used_at = :last_used_at
where token_validation = :validation_information
""".trimIndent()
)
.bind("last_used_at", now.epochSecond)
.bind("validation_information", token.tokenValidationInfo.validationInfo)
.execute()
}

override fun getTokenByTokenValidationInfo(tokenValidationInfo: TokenValidationInfo): Pair<User, Token>? =
handle.createQuery(
"""
select id, username, password_validation
from dbo.Users as users
inner join dbo.Tokens as tokens
on users.id = tokens.user_id
where token_validation = :validation_information
select id, username, password_validation, token_validation, created_at, last_used_at
from dbo.Users as users
inner join dbo.Tokens as tokens
on users.id = tokens.user_id
where token_validation = :validation_information
"""
)
.bind("validation_information", tokenValidationInfo.validationInfo)
.mapTo<User>()
.mapTo<UserAndTokenModel>()
.singleOrNull()
?.userAndToken

private data class UserAndTokenModel(
val id: Int,
val username: String,
val passwordValidation: PasswordValidationInfo,
val tokenValidation: TokenValidationInfo,
val createdAt: Long,
val lastUsedAt: Long,
) {
val userAndToken: Pair<User, Token>
get() = Pair(
User(id, username, passwordValidation),
Token(tokenValidation, id, Instant.ofEpochSecond(createdAt), Instant.ofEpochSecond(lastUsedAt))
)
}

companion object {
private val logger = LoggerFactory.getLogger(JdbiUsersRepository::class.java)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package pt.isel.daw.tictactow.service

import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component
import pt.isel.daw.tictactow.Clock
import pt.isel.daw.tictactow.Either
import pt.isel.daw.tictactow.domain.PasswordValidationInfo
import pt.isel.daw.tictactow.domain.Token
import pt.isel.daw.tictactow.domain.User
import pt.isel.daw.tictactow.domain.UserLogic
import pt.isel.daw.tictactow.repository.TransactionManager
import pt.isel.daw.tictactow.utils.TokenEncoder
import java.time.Duration

sealed class UserCreationError {
object UserAlreadyExists : UserCreationError()
Expand All @@ -26,6 +29,7 @@ class UsersService(
private val userLogic: UserLogic,
private val passwordEncoder: PasswordEncoder,
private val tokenEncoder: TokenEncoder,
private val clock: Clock,
) {

fun createUser(username: String, password: String): UserCreationResult {
Expand Down Expand Up @@ -59,7 +63,9 @@ class UsersService(
return@run Either.Left(TokenCreationError.UserOrPasswordAreInvalid)
}
val token = userLogic.generateToken()
usersRepository.createToken(user.id, tokenEncoder.createValidationInformation(token))
val now = clock.now()
val newToken = Token(tokenEncoder.createValidationInformation(token), user.id, now, now)
usersRepository.createToken(newToken, MAX_TOKENS)
Either.Right(token)
}
}
Expand All @@ -71,12 +77,30 @@ class UsersService(
return transactionManager.run {
val usersRepository = it.usersRepository
val tokenValidationInfo = tokenEncoder.createValidationInformation(token)
usersRepository.getUserByTokenValidationInfo(tokenValidationInfo)
val userAndToken = usersRepository.getTokenByTokenValidationInfo(tokenValidationInfo)
if (userAndToken != null && isTokenStillValid(userAndToken.second)) {
usersRepository.updateTokenLastUsed(userAndToken.second, clock.now())
userAndToken.first
} else {
null
}
}
}

private fun isTokenStillValid(token: Token): Boolean {
val now = clock.now()
return now.isBefore(token.createdAt.plus(TOKEN_TTL)) &&
now.isBefore(token.lastUsedAt.plus(TOKEN_ROLLING_TTL))
}

private fun userNotFound(): TokenCreationResult {
passwordEncoder.encode("changeit")
return Either.Left(TokenCreationError.UserOrPasswordAreInvalid)
}

companion object {
val TOKEN_ROLLING_TTL: Duration = Duration.ofHours(1)
val TOKEN_TTL: Duration = Duration.ofDays(1)
val MAX_TOKENS: Int = 3
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ package pt.isel.daw.tictactow.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import pt.isel.daw.tictactow.Clock
import pt.isel.daw.tictactow.RealClock
import pt.isel.daw.tictactow.utils.TestClock
import java.time.Duration
import java.time.Instant

class GameLogicTests {

Expand Down Expand Up @@ -216,15 +215,4 @@ class GameLogicTests {
private val alice = User(1, "alice", PasswordValidationInfo(""))
private val bob = User(2, "alice", PasswordValidationInfo(""))
}

class TestClock : Clock {

private var now = Instant.ofEpochSecond(0)

override fun now(): Instant = now

fun advance(duration: Duration) {
now += duration
}
}
}
Loading

0 comments on commit 2e77425

Please sign in to comment.