Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login on website #421

Merged
merged 9 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.xebia.functional.xef.server.db.psql.Migrate
import com.xebia.functional.xef.server.db.psql.XefVectorStoreConfig
import com.xebia.functional.xef.server.db.psql.XefVectorStoreConfig.Companion.getPersistenceService
import com.xebia.functional.xef.server.http.routes.routes
import com.xebia.functional.xef.server.services.UserRepositoryService
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
Expand All @@ -23,6 +24,7 @@ import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.resources.*
import io.ktor.server.routing.*
import kotlinx.coroutines.awaitCancellation
import org.jetbrains.exposed.sql.Database

object Server {
@JvmStatic
Expand All @@ -36,6 +38,12 @@ object Server {
val persistenceService = vectorStoreConfig.getPersistenceService(config)
persistenceService.addCollection()

Database.connect(
url = xefDBConfig.getUrl(),
user = xefDBConfig.user,
password = xefDBConfig.password
)

val ktorClient = HttpClient(CIO){
engine {
requestTimeout = 0 // disabled
Expand All @@ -62,7 +70,7 @@ object Server {
}
}
}
routing { routes(ktorClient, persistenceService) }
routing { routes(ktorClient, persistenceService, UserRepositoryService()) }
}
awaitCancellation()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ object Migrate {
config: XefDatabaseConfig,
): MigrateResult =
withContext(Dispatchers.IO) {
val url = "jdbc:postgresql://${config.host}:${config.port}/${config.database}"
val url = config.getUrl()
val migration: FluentConfiguration = Flyway.configure()
.dataSource(
url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class XefDatabaseConfig(
val migrationsTable: String,
val migrationsLocations: List<String>
) {

fun getUrl(): String = "jdbc:postgresql://$host:$port/$database"

companion object {
@OptIn(ExperimentalSerializationApi::class)
suspend fun load(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
object UsersTable : IntIdTable() {
val name = varchar("name", 20)
val email = varchar("email", 50)
val passwordHash = varchar("password_hash", 50)
val salt = varchar("salt", 20)
val passwordHash = binary("password_hash")
val salt = binary("salt")
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp())
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp())
val authToken = varchar("auth_token", 128)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.xebia.functional.xef.server.http.routes

import com.aallam.openai.api.BetaOpenAI
import com.xebia.functional.xef.server.models.LoginRequest
import com.xebia.functional.xef.server.models.LoginResponse
import com.xebia.functional.xef.server.models.RegisterRequest
import com.xebia.functional.xef.server.services.PersistenceService
import com.xebia.functional.xef.server.services.UserRepositoryService
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
Expand All @@ -12,7 +16,6 @@ import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.*
import io.ktor.util.pipeline.*
import io.ktor.utils.io.jvm.javaio.*
import kotlinx.serialization.json.Json
Expand All @@ -31,14 +34,34 @@ fun String.toProvider(): Provider? = when (this) {
else -> Provider.OPENAI
}


@OptIn(BetaOpenAI::class)
fun Routing.routes(
client: HttpClient,
persistenceService: PersistenceService
persistenceService: PersistenceService,
userRepositoryService: UserRepositoryService
) {
val openAiUrl = "https://api.openai.com/v1"

post("/register") {
try {
val request = Json.decodeFromString<RegisterRequest>(call.receive<String>())
val response = userRepositoryService.register(request)
call.respond(response)
} catch (e: Exception) {
call.respondText(e.message ?: "Unexpected error", status = HttpStatusCode.BadRequest)
}
}

post("/login") {
try {
val request = Json.decodeFromString<LoginRequest>(call.receive<String>())
val response = userRepositoryService.login(request)
call.respond(response)
} catch (e: Exception) {
call.respondText(e.message ?: "Unexpected error", status = HttpStatusCode.BadRequest)
}
}

authenticate("auth-bearer") {
post("/chat/completions") {
val token = call.getToken()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.xebia.functional.xef.server.models

import kotlinx.serialization.Serializable

@Serializable
data class LoginResponse(val authToken: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.xebia.functional.xef.server.models

import kotlinx.serialization.Serializable

@Serializable
data class RegisterRequest(
val name: String,
val email: String,
val password: String
)

@Serializable
data class LoginRequest(
val email: String,
val password: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.xebia.functional.xef.server.services

import com.xebia.functional.xef.server.db.tables.User
import com.xebia.functional.xef.server.db.tables.UsersTable
import com.xebia.functional.xef.server.models.LoginRequest
import com.xebia.functional.xef.server.models.LoginResponse
import com.xebia.functional.xef.server.models.RegisterRequest
import com.xebia.functional.xef.server.utils.HashUtils
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.uuid.UUID
import kotlinx.uuid.generateUUID
import org.jetbrains.exposed.sql.transactions.transaction

class UserRepositoryService {
val logger = KotlinLogging.logger {}

fun register(request: RegisterRequest): LoginResponse {
logger.info { "Registering user ${request.email}" }

return transaction {
if (User.find { UsersTable.email eq request.email }.count() > 0) {
throw Exception("User already exists")
}

val newSalt = HashUtils.generateSalt()
val passwordHashed = HashUtils.createHash(request.password, newSalt)
val user = transaction {
User.new {
name = request.name
email = request.email
passwordHash = passwordHashed
salt = newSalt
authToken = UUID.generateUUID(passwordHashed).toString()
}
}
LoginResponse(user.authToken)
}
}

fun login(request: LoginRequest): LoginResponse {
logger.info { "Login user ${request.email}" }
return transaction {
val user =
User.find { UsersTable.email eq request.email }.firstOrNull() ?: throw Exception("User not found")

if (!HashUtils.checkPassword(
request.password,
user.salt,
user.passwordHash
)
)
throw Exception("Invalid password")

LoginResponse(user.authToken)
}


}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.xebia.functional.xef.server.utils

import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.security.spec.InvalidKeySpecException
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec

object HashUtils {

fun generateSalt(): ByteArray {
val random = SecureRandom()
val salt = ByteArray(16)
random.nextBytes(salt)
return salt
}

fun checkPassword(password: String, salt: ByteArray, expectedHash: ByteArray): Boolean {
val pwdHash = createHash(password, salt)
if (pwdHash.size != expectedHash.size) return false
return pwdHash.indices.all { pwdHash[it] == expectedHash[it] }
}

fun createHash(password: String, salt: ByteArray): ByteArray {
val spec = PBEKeySpec(password.toCharArray(), salt, 1000, 256)
try {
val skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
return skf.generateSecret(spec).encoded
} catch (e: NoSuchAlgorithmException) {
throw AssertionError("Error: ${e.message}", e)
} catch (e: InvalidKeySpecException) {
throw AssertionError("Error: ${e.message}", e)
} finally {
spec.clearPassword()
}
}
}
4 changes: 2 additions & 2 deletions server/src/main/resources/db/migrations/psql/V1__Initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ CREATE TABLE IF NOT EXISTS users(
id SERIAL PRIMARY KEY,
name VARCHAR(20) NOT NULL,
email VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(50) NOT NULL,
salt VARCHAR(20) NOT NULL,
password_hash BYTEA NOT NULL,
salt BYTEA NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
auth_token VARCHAR(128) UNIQUE NOT NULL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ object DBHelpers {
return User.new {
name = fName
email = fEmail
passwordHash = fPasswordHash
salt = fSalt
passwordHash = fPasswordHash.toByteArray()
salt = fSalt.toByteArray()
authToken = fAuthToken
}
}
Expand Down
6 changes: 6 additions & 0 deletions server/web/src/components/Header/Header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@
height: 2rem;
width: auto;
}

.panel-right {
display: block;
text-align: right;
margin-left: auto;
}
23 changes: 19 additions & 4 deletions server/web/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import { AppBar, Box, Button, IconButton, Toolbar, Typography } from '@mui/material';
import { Menu } from '@mui/icons-material';

import logo from '@/assets/xef-brand-name.svg';

import styles from './Header.module.css';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/state/Auth';

export type HeaderProps = {
action: () => void;
};

export function Header({ action }: HeaderProps) {
const navigate = useNavigate();
const auth = useAuth();

const handleSubmit = () => {
auth.signout(() => {
navigate("/login", { replace: true });
});
};

return (
<Box className={styles.container} sx={{ flexGrow: 1 }}>
<AppBar position="fixed">
Expand All @@ -24,9 +35,13 @@ export function Header({ action }: HeaderProps) {
<Menu />
</IconButton>
<img className={styles.logo} src={logo} alt="Logo" />
<Typography variant="h5" component="div" sx={{ flexGrow: 1 }}>
Dashboard
</Typography>
<Button
className={styles.panelRight}
onClick={handleSubmit}
variant="text"
disableElevation>
<Typography variant="button">Logout</Typography>
</Button>
</Toolbar>
</AppBar>
</Box>
Expand Down
67 changes: 67 additions & 0 deletions server/web/src/components/Login/FormLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Box, Button, TextField, Typography } from '@mui/material';
import { ChangeEvent, useState } from 'react';

type FormLoginProps = { onHandleButton: (email: string, pass: string) => void }

export function FormLogin({ onHandleButton }: FormLoginProps) {
const [emailInput, setEmailInput] = useState<string>('');
const [passwordInput, setPasswordInput] = useState<string>('');

const emailHandleChange = (event: ChangeEvent<HTMLInputElement>) => {
setEmailInput(event.target.value);
};

const passwordHandleChange = (event: ChangeEvent<HTMLInputElement>) => {
setPasswordInput(event.target.value);
};

const disabledButton = passwordInput?.trim() == "" || emailInput?.trim() == "";

return (
<>
<Box
sx={{
my: 3,
}}>
<TextField
id="email"
label="Email"
value={emailInput}
onChange={emailHandleChange}
size="small"
sx={{
width: { xs: '100%', sm: 550 },
}}
/>
</Box>
<Box
sx={{
my: 3,
}}>
<TextField
id="password"
label="Password"
value={passwordInput}
type='password'
onChange={passwordHandleChange}
size="small"
sx={{
width: { xs: '100%', sm: 550 },
}}
/>
</Box>
<Box
sx={{
my: 3,
}}>
<Button
onClick={() => onHandleButton(emailInput, passwordInput)}
variant="contained"
disableElevation
disabled={disabledButton}>
<Typography variant="button">{"Login"}</Typography>
</Button>
</Box>
</>
)
}
Loading