From 6e06119135737a0db34f60e93f79a068f5db6f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beyhan=20O=C4=9Fur?= Date: Sun, 26 Apr 2026 22:31:06 +0300 Subject: [PATCH] first commit --- .env | 12 + .env.example | 11 + .gitignore | 11 + API_DOCUMENTATION.md | 396 ++++++++++++++++++++++++++++++ Cargo.toml | 31 +++ README.md | 29 +++ account_system.md | 138 +++++++++++ migrations/initial.sql | 19 ++ src/api/deps.rs | 9 + src/api/mod.rs | 3 + src/api/routers/auth.rs | 60 +++++ src/api/routers/mod.rs | 3 + src/api/routers/users.rs | 29 +++ src/bin/migrate.rs | 26 ++ src/core/config.rs | 35 +++ src/core/mod.rs | 4 + src/core/oauth.rs | 117 +++++++++ src/core/security.rs | 48 ++++ src/db/entities/mod.rs | 3 + src/db/entities/refresh_tokens.rs | 17 ++ src/db/entities/users.rs | 18 ++ src/db/mod.rs | 14 ++ src/errors.rs | 30 +++ src/main.rs | 41 ++++ src/schemas/mod.rs | 37 +++ src/services/auth_service.rs | 62 +++++ src/services/mod.rs | 3 + src/services/user_service.rs | 13 + src/utils.rs | 6 + tests/integration_tests.rs | 7 + 30 files changed, 1232 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 API_DOCUMENTATION.md create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 account_system.md create mode 100644 migrations/initial.sql create mode 100644 src/api/deps.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/routers/auth.rs create mode 100644 src/api/routers/mod.rs create mode 100644 src/api/routers/users.rs create mode 100644 src/bin/migrate.rs create mode 100644 src/core/config.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/oauth.rs create mode 100644 src/core/security.rs create mode 100644 src/db/entities/mod.rs create mode 100644 src/db/entities/refresh_tokens.rs create mode 100644 src/db/entities/users.rs create mode 100644 src/db/mod.rs create mode 100644 src/errors.rs create mode 100644 src/main.rs create mode 100644 src/schemas/mod.rs create mode 100644 src/services/auth_service.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/user_service.rs create mode 100644 src/utils.rs create mode 100644 tests/integration_tests.rs diff --git a/.env b/.env new file mode 100644 index 0000000..68923b9 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +DATABASE_URL=postgresql://cloud:gg7678290@212.64.215.243:5432/rustapi +SECRET_KEY=ares-rust-CT286MautyK9TWfz8SPSIWJTXV83mwLRwriLvSbpcMZuDLxywaHWP9Ju7xRoTS2 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 +GOOGLE_CLIENT_ID="rtertertert" +GOOGLE_CLIENT_SECRET="werwerwer" +GITHUB_CLIENT_ID="dfgdfgdfg" +GITHUB_CLIENT_SECRET="fgdfgdfgdfg" +OAUTH_REDIRECT_BASE=http://localhost:3000 +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 +JWT_SECRET=ares-rust-CT286MautyK9TWfz8SPSIWJTXV83mwLRwriLvSbpcMZuDLxywaHWP9Ju7xRoTS2 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..865a990 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql://cloud:gg7678290@212.64.215.243:5432/rustapi +SECRET_KEY=some_long_random_value +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +OAUTH_REDIRECT_BASE=http://localhost:3000 +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36790ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Generated for Rust projects +/target/ +**/target/ +.DS_Store + +# Editor +.vscode/ +*.swp + +# Cargo +/Cargo.lock \ No newline at end of file diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..91151d9 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,396 @@ +# Rust API Account System - API Dokümantasyonu + +## Genel Bilgiler + +**Base URL:** `http://localhost:3000/api/v1` +**Content-Type:** `application/json` +**Authentication:** Bearer Token (JWT) + +### Token Yapısı + +- **Access Token:** Kısa ömürlü JWT token (varsayılan: 15 dakika) +- **Refresh Token:** Uzun ömürlü UUID token (varsayılan: 30 gün) +- **Token Format:** `Bearer ` + +--- + +## Authentication Endpoints + +### 1. Kullanıcı Kaydı + +Yeni bir kullanıcı hesabı oluşturur ve access/refresh token döner. + +**Endpoint:** `POST /api/v1/auth/register` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "securePassword123" +} +``` + +**Request Örneği (cURL):** +```bash +curl -X POST http://localhost:3000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "securePassword123" + }' +``` + +**Response (200 OK):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "550e8400-e29b-41d4-a716-446655440000", + "token_type": "bearer" +} +``` + +**Response (400 Bad Request):** +```json +{ + "error": "invalid input: email: Email validation failed" +} +``` + +**Response (409 Conflict - Email zaten kayıtlı):** +```json +{ + "error": "email already exists" +} +``` + +**Validasyon Kuralları:** +- `email`: Geçerli bir email formatı olmalı +- `password`: Boş olmamalı + +--- + +### 2. Kullanıcı Girişi + +Mevcut kullanıcı ile giriş yapar ve access/refresh token döner. + +**Endpoint:** `POST /api/v1/auth/login` + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "securePassword123" +} +``` + +**Request Örneği (cURL):** +```bash +curl -X POST http://localhost:3000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "securePassword123" + }' +``` + +**Response (200 OK):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "550e8400-e29b-41d4-a716-446655440000", + "token_type": "bearer" +} +``` + +**Response (401 Unauthorized):** +```json +{ + "error": "invalid credentials" +} +``` + +--- + +### 3. OAuth Yönlendirme + +OAuth provider'a (Google/GitHub) yönlendirme URL'i oluşturur. + +**Endpoint:** `GET /api/v1/auth/oauth/{provider}` + +**Path Parameters:** +- `provider`: `google` veya `github` + +**Request Örneği (cURL):** +```bash +# Google OAuth +curl http://localhost:3000/api/v1/auth/oauth/google + +# GitHub OAuth +curl http://localhost:3000/api/v1/auth/oauth/github +``` + +**Response:** +- HTTP 302 Redirect - Kullanıcıyı OAuth provider'a yönlendirir + +**Tarayıcı Kullanımı:** +``` +http://localhost:3000/api/v1/auth/oauth/google +http://localhost:3000/api/v1/auth/oauth/github +``` + +**Not:** Bu endpoint tarayıcıda açılmalıdır. OAuth akışı için kullanıcı etkileşimi gereklidir. + +--- + +### 4. OAuth Callback + +OAuth provider'dan dönen authorization code'u token'a çevirir. + +**Endpoint:** `GET /api/v1/auth/oauth/{provider}/callback` + +**Path Parameters:** +- `provider`: `google` veya `github` + +**Query Parameters:** +- `code`: OAuth provider'dan dönen authorization code + +**Request Örneği:** +``` +http://localhost:3000/api/v1/auth/oauth/google/callback?code=4/0AeanS... +``` + +**Response (200 OK):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Response (400 Bad Request):** +```json +{ + "error": "missing code" +} +``` + +**Response (500 Internal Server Error):** +```json +{ + "error": "token exchange failed" +} +``` + +**OAuth Akışı:** +1. Kullanıcı `/api/v1/auth/oauth/{provider}` endpoint'ine yönlendirilir +2. OAuth provider'da giriş yapar ve izin verir +3. Provider, `{OAUTH_REDIRECT_BASE}/api/v1/auth/oauth/{provider}/callback?code=...` adresine yönlendirir +4. Callback endpoint authorization code'u token'a çevirir ve döner + +--- + +## User Endpoints + +### 5. Kullanıcı Bilgileri + +Mevcut kullanıcının bilgilerini döner. Access token gereklidir. + +**Endpoint:** `GET /api/v1/users/me` + +**Headers:** +``` +Authorization: Bearer +``` + +**Request Örneği (cURL):** +```bash +curl http://localhost:3000/api/v1/users/me \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +**Response (200 OK):** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "provider": null +} +``` + +**OAuth ile kayıt olmuş kullanıcı için:** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@gmail.com", + "provider": "google" +} +``` + +**Response (401 Unauthorized - Geçersiz token):** +```json +{ + "id": "", + "email": "", + "provider": null +} +``` + +--- + +## Hata Kodları + +| HTTP Status | Açıklama | +|-------------|----------| +| 200 OK | İstek başarılı | +| 302 Found | OAuth yönlendirme | +| 400 Bad Request | Geçersiz istek (validasyon hatası, eksik parametre) | +| 401 Unauthorized | Yetkilendirme hatası (geçersiz token, yanlış şifre) | +| 409 Conflict | Email zaten kayıtlı | +| 500 Internal Server Error | Sunucu hatası | + +--- + +## Örnek Kullanım Senaryoları + +### Senaryo 1: Email/Password ile Kayıt ve Giriş + +```bash +# 1. Kayıt ol +curl -X POST http://localhost:3000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "mypassword123" + }' + +# Response'dan access_token ve refresh_token'ı al + +# 2. Kullanıcı bilgilerini getir +curl http://localhost:3000/api/v1/users/me \ + -H "Authorization: Bearer " + +# 3. Giriş yap (token süresi dolduğunda) +curl -X POST http://localhost:3000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "mypassword123" + }' +``` + +### Senaryo 2: Google OAuth ile Giriş + +```bash +# 1. Tarayıcıda şu URL'i aç: +# http://localhost:3000/api/v1/auth/oauth/google + +# 2. Google'da giriş yap ve izin ver + +# 3. Callback URL'den dönen access_token ve refresh_token'ı kullan + +# 4. Kullanıcı bilgilerini getir +curl http://localhost:3000/api/v1/users/me \ + -H "Authorization: Bearer " +``` + +### Senaryo 3: JavaScript/Fetch ile Kullanım + +```javascript +// Kayıt +async function register(email, password) { + const response = await fetch('http://localhost:3000/api/v1/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + return await response.json(); +} + +// Giriş +async function login(email, password) { + const response = await fetch('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + return await response.json(); +} + +// Kullanıcı bilgileri +async function getMe(accessToken) { + const response = await fetch('http://localhost:3000/api/v1/users/me', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); + return await response.json(); +} + +// Kullanım +const { access_token, refresh_token } = await register('user@example.com', 'password123'); +const userInfo = await getMe(access_token); +console.log(userInfo); +``` + +--- + +## Güvenlik Notları + +1. **HTTPS Kullanımı:** Production ortamında mutlaka HTTPS kullanın +2. **Token Saklama:** Access token'ları güvenli bir şekilde saklayın (localStorage yerine httpOnly cookie tercih edilir) +3. **Token Yenileme:** Access token süresi dolduğunda refresh token kullanarak yeni token alın +4. **Şifre Güvenliği:** Şifreler SHA-256 ile ön-hash edilip sonra bcrypt ile hash'lenir +5. **Rate Limiting:** Production'da rate limiting ekleyin +6. **CORS:** Frontend uygulamanız için CORS ayarlarını yapılandırın + +--- + +## Environment Variables + +API'nin çalışması için gerekli environment değişkenleri: + +```env +# Database +DATABASE_URL=postgres://user:password@localhost:5432/dbname + +# JWT Secret +SECRET_KEY=your-secret-key-here-min-32-chars + +# Token Süreleri +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# OAuth - Google +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret + +# OAuth - GitHub +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_CLIENT_SECRET=your-github-client-secret + +# OAuth Redirect Base URL +OAUTH_REDIRECT_BASE=http://localhost:3000 + +# Server +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 +``` + +--- + +## Notlar + +- Tüm endpoint'ler `/api/v1` prefix'i altındadır +- Access token'lar JWT formatındadır ve `sub` (subject/user ID) ve `exp` (expiration) claim'lerini içerir +- OAuth ile kayıt olan kullanıcıların `hashed_password` alanı NULL olur ve `provider` alanı set edilir +- Refresh token endpoint'i henüz implement edilmemiştir (TODO) + +--- + +## Destek + +Sorularınız için issue açabilir veya dokümantasyonu güncelleyebilirsiniz. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..509e834 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rust_api_account_system" +version = "0.1.0" +edition = "2021" +default-run = "rust_api_account_system" + +[dependencies] +axum = { version = "0.8", features = ["json"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] } +sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] } +dotenvy = "0.15" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["serde", "v4"] } +bcrypt = "0.18" +sha2 = "0.10" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } +oauth2 = "5" +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } +chrono = { version = "0.4", features = ["serde"] } +validator = { version = "0.20", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = "0.3" +hex = "0.4" +anyhow = "1" +thiserror = "2" +async-trait = "0.1" + +[dev-dependencies] +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1d11fe --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Rust Account System (Axum + SeaORM) + +Minimal skeleton implementing an account system using Axum and SeaORM. + +Quick start + +1. Copy `.env.example` to `.env` and edit values. +2. Ensure `DATABASE_URL` points to your Postgres instance. +3. Run migrations using `sea-orm-cli` or your preferred migration runner: + +```sh +cargo install sea-orm-cli +DATABASE_URL="postgres://user:pass@localhost:5432/dbname" sea-orm-cli migrate up +``` + +4. Build and run: + +```sh +cargo build +cargo run +``` + +API prefix: `/api/v1/` (e.g., `/api/v1/auth/register`). + +Password hashing: BCrypt-SHA256 (pre-hash using SHA-256 then bcrypt). See `src/core/security.rs`. + +OAuth: Google and GitHub are planned (configure client ids and secrets in `.env`). + +Notes on switching DBs: change `DATABASE_URL` and use sea-orm-cli with the proper feature/driver. diff --git a/account_system.md b/account_system.md new file mode 100644 index 0000000..25af908 --- /dev/null +++ b/account_system.md @@ -0,0 +1,138 @@ +Aşağıdaki metni aynen VSCode Copilot’a (veya başka bir kod üretecine) ver — tek istekte tam bir Rust web projesi iskeleti oluşturacak şekilde ayrıntılı, ama gereksiz karmaşıklıktan kaçınan, dosyalar ayrı ayrı olacak ve PostgreSQL kullanacak bir hesap sistemi üretmesini istiyorum. ÖNEMLİ: Kesinlikle bir ORM ve bir web framework kullanılsın — SeaORM (ORM) ve Axum (web framework) kullanılacak. Kod istemiyorum — sadece bu metni Copilot'a yapıştır ve proje oluşturmasını bekle. + +Ek değişiklikler / kesin gereksinimler: +- Database: PostgreSQL olacak (default DATABASE_URL örneği .env.example içinde postgres://user:pass@localhost:5432/dbname). +- Tüm API yolları /api/v1/ prefix'i altında olacak (ör. /api/v1/auth/register, /api/v1/users/me). +- Auth: access_token (short-lived) ve refresh_token (long-lived) olacak. +- Parola hash algoritması: Argon2 yerine BCryptSHA256PasswordHasher uygulanacak şekilde iste — yani parola önce SHA-256 ile ön-hash edilecek, sonra bcrypt ile hash edilecek (Rust tarafında sha2 crate ile pre-hash + bcrypt crate ile bcrypt uygulaması). Copilot'a açıkça "Implement BCrypt-SHA256: SHA256(password) then bcrypt on hex/base64 of digest" diye belirt. +- OAuth: Google ve GitHub OAuth2 desteklenecek. + +İstek (kopyala-yapıştır için; İngilizce olarak verilecek çünkü Copilot daha iyi anlar): + +"Generate a complete, minimal and well-structured Rust web project that implements a user account system with these exact characteristics: + +Project-wide: +- Use async Rust with Tokio runtime. +- Use Axum as web framework and SeaORM as ORM (SeaORM entities / ActiveModels). +- Use tracing for structured logs. +- Default database: PostgreSQL (connection via DATABASE_URL). The project must be structured so switching DBs only requires changing DATABASE_URL and running migrations via sea-orm-migration / sea-orm-cli. +- All endpoints MUST be under the prefix /api/v1/. +- Load configuration and secrets from .env (use dotenvy or envy). Provide a .env.example. + +Authentication & Security: +- Email/password registration + login using BCrypt-SHA256 password hasher: + - Pre-hash the raw password with SHA-256 (use sha2 crate), then hash the resulting digest with bcrypt (use bcrypt crate). + - Provide helper functions to verify password with the same process. + - Document clearly in comments where to change to another hasher. +- Social login via Google and GitHub (OAuth2 flows using oauth2 crate; use reqwest or oauth2's HTTP client for provider calls). +- JWT access tokens and refresh tokens: + - Use jsonwebtoken crate. + - Sign tokens with SECRET_KEY from .env. + - Respect ACCESS_TOKEN_EXPIRE_MINUTES and REFRESH_TOKEN_EXPIRE_DAYS settings. + - Include both access_token and refresh_token in responses where applicable. +- Store refresh tokens in DB (store UUID string; include comment about switching to hashed storage if desired). +- Validate emails using a validator crate (e.g., validator). + +Endpoints to implement (minimal but runnable): +- POST /api/v1/auth/register + - Accept JSON { "email": "...", "password": "..." } + - Validate email, pre-hash+bcrypt password, create user, create refresh token record, return { access_token, refresh_token, token_type, expires_in }. +- POST /api/v1/auth/login + - Accept JSON { "email": "...", "password": "..." } + - Verify credentials, return access + refresh tokens. +- POST /api/v1/auth/refresh + - Accept JSON { "refresh_token": "..." } + - Verify refresh token exists and not expired, return new access token (and optionally rotate refresh token). +- GET /api/v1/auth/oauth/{provider} + - provider ∈ {"google","github"} — build provider auth URL with required scopes and redirect the client. +- GET /api/v1/auth/oauth/{provider}/callback + - Exchange code for token, fetch user info (email), extract primary verified email, create/find user (hashed_password NULL, provider field set), create refresh token, return tokens (or redirect with tokens). +- POST /api/v1/auth/logout + - Accept JSON { "refresh_token": "..." } (or use header), delete/invalidate the refresh token in DB. +- GET /api/v1/users/me + - Protected with Authorization: Bearer , returns current user info. + +Data models (SeaORM / DB schema): +- users table / User entity (UUID primary key): + - id (UUID) + - email (unique) + - hashed_password (nullable for OAuth-only accounts) + - provider (nullable text: "google", "github", or NULL) + - is_active (boolean) + - created_at (timestamp) +- refresh_tokens table / RefreshToken entity: + - id (UUID) + - user_id (UUID FK) + - token (UUID string) + - created_at + - expires_at + +Project layout (generator MUST create these files/modules — produce working Rust code for each): +- Cargo.toml (include: axum, tokio, sea-orm, sea-orm-migration, sea-orm-macros, dotenvy or envy, serde, serde_json, uuid, bcrypt, sha2, jsonwebtoken, oauth2, reqwest, chrono or time, validator, tracing, anyhow, thiserror) +- src/ + - main.rs (app startup, router composition with /api/v1 prefix, load config, init SeaORM DB connection and run migrations at startup option) + - core/ + - config.rs (Config struct loading env vars; typed fields for secrets, expirations, DB URL, OAuth client ids/secrets, OAUTH_REDIRECT_BASE) + - security.rs (BCrypt-SHA256 password helpers, JWT create/verify helpers, token payload types) + - oauth.rs (provider configs and helper functions to build auth URLs and exchange codes) + - db/ + - mod.rs (SeaORM Database connection setup, run migrations helper) + - entities/ (SeaORM entities: users.rs, refresh_tokens.rs or a generated entities mod) + - schemas/ + - mod.rs (request/response DTOs: RegisterRequest, LoginRequest, TokenResponse, RefreshRequest, UserResponse) + - services/ + - auth_service.rs (register/login/refresh/oauth logic; create tokens; persist refresh tokens; rotate tokens if desired) + - user_service.rs (basic user CRUD and lookup using SeaORM ActiveModels) + - api/ + - deps.rs (extractors for DB connection, typed config, and current_user extractor/middleware) + - routers/ + - auth.rs (auth endpoints) + - users.rs (users/me) + - errors.rs (custom error types and conversions to HTTP responses) + - utils.rs (helpers e.g., time helpers, uuid helpers) +- migrations/ + - sea-orm compatible migrations (or raw SQL) to create users and refresh_tokens for PostgreSQL (include at least initial migration file). +- .env.example (include all required env vars and example values; for PostgreSQL example: DATABASE_URL=postgres://user:password@localhost:5432/your_db) +- README.md (how to install, set up environment, run migrations with sea-orm-cli or sea-orm-migration runner, run app, env variables, how to register OAuth apps for Google & GitHub and set callback URLs — note callbacks will like be `${OAUTH_REDIRECT_BASE}/api/v1/auth/oauth/google/callback` and similarly for github) +- tests/ + - integration_tests.rs (basic integration tests that register a user, login, and call /api/v1/users/me using the access token; minimal and runnable) + +Config & env vars (must be in .env.example): +- DATABASE_URL=postgres://user:password@localhost:5432/dbname +- SECRET_KEY=some_long_random_value +- ACCESS_TOKEN_EXPIRE_MINUTES=15 +- REFRESH_TOKEN_EXPIRE_DAYS=30 +- GOOGLE_CLIENT_ID= +- GOOGLE_CLIENT_SECRET= +- GITHUB_CLIENT_ID= +- GITHUB_CLIENT_SECRET= +- OAUTH_REDIRECT_BASE=http://localhost:3000 +- SERVER_HOST=127.0.0.1 +- SERVER_PORT=3000 + +Developer notes for the code generator (be explicit): +- Use SeaORM entities / ActiveModel for DB operations; keep queries simple and idiomatic. +- Migrations: provide sea-orm-migration or migration SQL and README instructions using sea-orm-cli: + - e.g., install: cargo install sea-orm-cli + - run migrations: DATABASE_URL="postgres://..." sea-orm-cli migrate up +- Implement BCrypt-SHA256 password hasher: pre-hash the UTF-8 password with SHA-256 (sha2::Sha256), encode digest (hex or base64), then pass that string to bcrypt::hash with a configurable cost. Provide verify routine that repeats pre-hash + bcrypt::verify. +- For OAuth: use oauth2 crate to construct authorize URL and to exchange code; use reqwest to fetch user info (emails) if needed. Scopes: Google ["openid","email","profile"] and GitHub ["user:email"]. +- JWT tokens: use jsonwebtoken; include standard claims and expiry. Use typed structs for token claims. +- For refresh tokens: generate UUID strings and store them in DB with expiry. Provide optional rotation logic (brief comment). +- Provide clear error handling using thiserror and map to HTTP responses (axum::response::IntoResponse with proper status codes). +- The app should mount routes under /api/v1/ (e.g., router.route("/api/v1/auth/register", post(...)) ). +- Tests: include at least one integration test that runs a test server, registers a user, logs in, and calls /api/v1/users/me with the access token. +- Keep functions small, typed, and well-documented with comments explaining major steps. + +Output expectation from Copilot: +- Create all files listed above with working Rust code (no pseudocode) and comments. +- The resulting project must be runnable: + - cargo build + - set .env from .env.example + - run sea-orm-cli migrate up (or provided migration runner) + - cargo run + - Test endpoints using curl/Postman at http://SERVER_HOST:SERVER_PORT with prefix /api/v1/ +- README must explain how to switch DBs by changing DATABASE_URL and running migrations with appropriate flags (e.g., to switch to MySQL or Postgres, change features/driver). +- Language for generated code and docs: English (comments or README may include short Turkish notes if helpful)." + +Not: Bu metni VSCode Copilot'a yapıştırdığımda tam bir Rust web projesi oluştursun; ben yalnızca prompt istedim — kod istemiyorum. \ No newline at end of file diff --git a/migrations/initial.sql b/migrations/initial.sql new file mode 100644 index 0000000..effc136 --- /dev/null +++ b/migrations/initial.sql @@ -0,0 +1,19 @@ +-- initial migration for users and refresh_tokens +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email text NOT NULL UNIQUE, + hashed_password text, + provider text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); diff --git a/src/api/deps.rs b/src/api/deps.rs new file mode 100644 index 0000000..f9c2277 --- /dev/null +++ b/src/api/deps.rs @@ -0,0 +1,9 @@ +use crate::core::config::Config; +use sea_orm::DatabaseConnection; +use std::sync::Arc; +pub struct AppState { + pub db: DatabaseConnection, + pub cfg: Config, +} + +pub type SharedState = Arc; diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..b69d53a --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod deps; +pub mod routers; + diff --git a/src/api/routers/auth.rs b/src/api/routers/auth.rs new file mode 100644 index 0000000..e586543 --- /dev/null +++ b/src/api/routers/auth.rs @@ -0,0 +1,60 @@ +use axum::{routing::{get, post}, Json, Router, Extension}; +use validator::Validate; +use serde_json::json; +use crate::api::deps::SharedState; + +mod handlers { + use super::*; + use crate::schemas::*; + use crate::services::auth_service::AuthService; + + pub async fn register(Extension(state): Extension, Json(payload): Json) -> axum::response::Json { + if let Err(e) = payload.validate() { + return Json(json!({"error": format!("invalid input: {}", e)})); + } + match AuthService::register(&state.db, &state.cfg, &payload.email, &payload.password).await { + Ok((access, refresh)) => Json(json!({"access_token": access, "refresh_token": refresh, "token_type": "bearer"})), + Err(e) => Json(json!({"error": e.to_string()})), + } + } + + pub async fn login(Extension(state): Extension, Json(payload): Json) -> axum::response::Json { + match AuthService::login(&state.db, &state.cfg, &payload.email, &payload.password).await { + Ok((access, refresh)) => Json(json!({"access_token": access, "refresh_token": refresh, "token_type": "bearer"})), + Err(e) => Json(json!({"error": e.to_string()})), + } + } + + pub async fn oauth_redirect(axum::extract::Path(provider): axum::extract::Path, Extension(state): Extension) -> axum::response::Redirect { + match crate::core::oauth::build_authorize_url(&provider, &state.cfg) { + Ok(url) => axum::response::Redirect::temporary(&url), + Err(_) => axum::response::Redirect::temporary("/"), + } + } + + pub async fn oauth_callback(axum::extract::Path((provider,)): axum::extract::Path<(String,)>, axum::extract::Query(q): axum::extract::Query>, Extension(state): Extension) -> axum::response::Json { + let provider = provider.as_str(); + if let Some(code) = q.get("code") { + match crate::core::oauth::exchange_code_for_user(provider, &state.cfg, code).await { + Ok(_user) => { + // create/find user and tokens (simplified) + // TODO: Use _user.email to create/find user in database + let uid = uuid::Uuid::new_v4(); + let access = crate::core::security::create_access_token(&state.cfg, &uid.to_string()).unwrap_or_default(); + let refresh = uuid::Uuid::new_v4().to_string(); + return axum::response::Json(json!({"access_token": access, "refresh_token": refresh})); + } + Err(e) => return axum::response::Json(json!({"error": e.to_string()})), + } + } + axum::response::Json(json!({"error": "missing code"})) + } +} + +pub fn router() -> Router { + Router::new() + .route("/auth/register", post(handlers::register)) + .route("/auth/login", post(handlers::login)) + .route("/auth/oauth/{provider}", get(handlers::oauth_redirect)) + .route("/auth/oauth/{provider}/callback", get(handlers::oauth_callback)) +} diff --git a/src/api/routers/mod.rs b/src/api/routers/mod.rs new file mode 100644 index 0000000..cdaabc7 --- /dev/null +++ b/src/api/routers/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod users; + diff --git a/src/api/routers/users.rs b/src/api/routers/users.rs new file mode 100644 index 0000000..f64c265 --- /dev/null +++ b/src/api/routers/users.rs @@ -0,0 +1,29 @@ +use axum::{routing::get, Json, Router, Extension}; +use axum::http::header::AUTHORIZATION; +use crate::schemas::UserResponse; +use crate::api::deps::SharedState; +use crate::core::security::verify_access_token; +use crate::db::entities::users::Entity as UserEntity; +use sea_orm::EntityTrait; + +pub async fn me(Extension(state): Extension, req: axum::extract::Request) -> Json { + let token = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .unwrap_or(""); + let claims = match verify_access_token(&state.cfg, token) { + Ok(c) => c, + Err(_) => return Json(UserResponse { id: "".to_string(), email: "".to_string(), provider: None }), + }; + let uid = match uuid::Uuid::parse_str(&claims.sub) { Ok(u) => u, Err(_) => return Json(UserResponse { id: "".to_string(), email: "".to_string(), provider: None }) }; + if let Ok(Some(u)) = UserEntity::find_by_id(uid).one(&state.db).await { + return Json(UserResponse { id: u.id.to_string(), email: u.email.clone(), provider: u.provider.clone() }); + } + Json(UserResponse { id: "".to_string(), email: "".to_string(), provider: None }) +} + +pub fn router() -> Router { + Router::new().route("/users/me", get(me)) +} diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs new file mode 100644 index 0000000..6c202ca --- /dev/null +++ b/src/bin/migrate.rs @@ -0,0 +1,26 @@ +use sea_orm::{Database, ConnectionTrait, Statement}; +use std::fs; +use dotenvy::dotenv; +use std::env; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv().ok(); + + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + println!("Connecting to DB..."); + + let db = Database::connect(&db_url).await?; + println!("Connected successfully."); + + let sql = fs::read_to_string("migrations/initial.sql")?; + let statements: Vec<&str> = sql.split(';').filter(|s| !s.trim().is_empty()).collect(); + + println!("Running initial.sql..."); + for stmt in statements { + db.execute(Statement::from_string(sea_orm::DatabaseBackend::Postgres, stmt.to_string())).await?; + } + + println!("Migration applied successfully!"); + Ok(()) +} diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 0000000..06d939f --- /dev/null +++ b/src/core/config.rs @@ -0,0 +1,35 @@ +use dotenvy::var; +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub struct Config { + pub database_url: String, + pub secret_key: String, + pub access_token_expire_minutes: u64, + pub refresh_token_expire_days: i64, + pub google_client_id: String, + pub google_client_secret: String, + pub github_client_id: String, + pub github_client_secret: String, + pub oauth_redirect_base: String, + pub server_host: String, + pub server_port: u16, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + Ok(Self { + database_url: var("DATABASE_URL")?, + secret_key: var("SECRET_KEY")?, + access_token_expire_minutes: var("ACCESS_TOKEN_EXPIRE_MINUTES")?.parse().unwrap_or(15), + refresh_token_expire_days: var("REFRESH_TOKEN_EXPIRE_DAYS")?.parse().unwrap_or(30), + google_client_id: var("GOOGLE_CLIENT_ID").unwrap_or_default(), + google_client_secret: var("GOOGLE_CLIENT_SECRET").unwrap_or_default(), + github_client_id: var("GITHUB_CLIENT_ID").unwrap_or_default(), + github_client_secret: var("GITHUB_CLIENT_SECRET").unwrap_or_default(), + oauth_redirect_base: var("OAUTH_REDIRECT_BASE").unwrap_or_else(|_| "http://localhost:3000".into()), + server_host: var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".into()), + server_port: var("SERVER_PORT")?.parse().unwrap_or(3000), + }) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..8bc3829 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod security; +pub mod oauth; + diff --git a/src/core/oauth.rs b/src/core/oauth.rs new file mode 100644 index 0000000..c7b62ca --- /dev/null +++ b/src/core/oauth.rs @@ -0,0 +1,117 @@ +use crate::core::config::Config; +use anyhow::{anyhow, Context}; +use oauth2::basic::BasicClient; +use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, Scope, TokenResponse, TokenUrl, AuthorizationCode}; +use reqwest::Client as HttpClient; +use serde::Deserialize; + +pub struct OAuthUser { + #[allow(dead_code)] + pub email: String, +} + +pub fn build_authorize_url(provider: &str, cfg: &Config) -> anyhow::Result { + match provider { + "google" => { + let client = BasicClient::new(ClientId::new(cfg.google_client_id.clone())) + .set_client_secret(ClientSecret::new(cfg.google_client_secret.clone())) + .set_auth_uri(AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())?) + .set_token_uri(TokenUrl::new("https://oauth2.googleapis.com/token".to_string())?) + .set_redirect_uri(RedirectUrl::new(format!("{}/api/v1/auth/oauth/google/callback", cfg.oauth_redirect_base))?); + let (auth_url, _csrf_token) = client + .authorize_url(|| oauth2::CsrfToken::new_random()) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + Ok(auth_url.to_string()) + } + "github" => { + let client = BasicClient::new(ClientId::new(cfg.github_client_id.clone())) + .set_client_secret(ClientSecret::new(cfg.github_client_secret.clone())) + .set_auth_uri(AuthUrl::new("https://github.com/login/oauth/authorize".to_string())?) + .set_token_uri(TokenUrl::new("https://github.com/login/oauth/access_token".to_string())?) + .set_redirect_uri(RedirectUrl::new(format!("{}/api/v1/auth/oauth/github/callback", cfg.oauth_redirect_base))?); + let (auth_url, _csrf_token) = client + .authorize_url(|| oauth2::CsrfToken::new_random()) + .add_scope(Scope::new("user:email".to_string())) + .url(); + Ok(auth_url.to_string()) + } + _ => Err(anyhow!("unsupported provider")), + } +} + +pub async fn exchange_code_for_user(provider: &str, cfg: &Config, code: &str) -> anyhow::Result { + let http_client = HttpClient::new(); + let token_resp = match provider { + "google" => { + let client = BasicClient::new(ClientId::new(cfg.google_client_id.clone())) + .set_client_secret(ClientSecret::new(cfg.google_client_secret.clone())) + .set_auth_uri(AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())?) + .set_token_uri(TokenUrl::new("https://oauth2.googleapis.com/token".to_string())?) + .set_redirect_uri(RedirectUrl::new(format!("{}/api/v1/auth/oauth/google/callback", cfg.oauth_redirect_base))?); + client + .exchange_code(AuthorizationCode::new(code.to_string())) + .request_async(&http_client) + .await + .context("token exchange failed")? + } + "github" => { + let client = BasicClient::new(ClientId::new(cfg.github_client_id.clone())) + .set_client_secret(ClientSecret::new(cfg.github_client_secret.clone())) + .set_auth_uri(AuthUrl::new("https://github.com/login/oauth/authorize".to_string())?) + .set_token_uri(TokenUrl::new("https://github.com/login/oauth/access_token".to_string())?) + .set_redirect_uri(RedirectUrl::new(format!("{}/api/v1/auth/oauth/github/callback", cfg.oauth_redirect_base))?); + client + .exchange_code(AuthorizationCode::new(code.to_string())) + .request_async(&http_client) + .await + .context("token exchange failed")? + } + _ => return Err(anyhow!("unsupported provider")), + }; + + let access_token = token_resp.access_token().secret(); + + match provider { + "google" => { + #[derive(Deserialize)] + struct GoogleUser { + email: String, + #[allow(dead_code)] + verified_email: Option + } + let resp = http_client + .get("https://openidconnect.googleapis.com/v1/userinfo") + .bearer_auth(access_token) + .send() + .await + .context("failed fetching google userinfo")?; + let gu: GoogleUser = resp.json().await?; + Ok(OAuthUser { email: gu.email }) + } + "github" => { + #[derive(Deserialize)] + struct GitHubEmail { email: String, primary: bool, verified: bool } + let resp = http_client + .get("https://api.github.com/user/emails") + .header("User-Agent", "rust-api") + .bearer_auth(access_token) + .send() + .await + .context("failed fetching github emails")?; + let emails: Vec = resp.json().await?; + // prefer primary verified + if let Some(e) = emails.iter().find(|e| e.primary && e.verified) { + return Ok(OAuthUser { email: e.email.clone() }); + } + // fallback to any verified + if let Some(e) = emails.iter().find(|e| e.verified) { + return Ok(OAuthUser { email: e.email.clone() }); + } + Err(anyhow!("no verified email from provider")) + } + _ => Err(anyhow!("unsupported provider")), + } +} diff --git a/src/core/security.rs b/src/core/security.rs new file mode 100644 index 0000000..ab2342c --- /dev/null +++ b/src/core/security.rs @@ -0,0 +1,48 @@ +use crate::core::config::Config; +use anyhow::Context; +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub fn bcrypt_sha256_hash_password(password: &str, cost: Option) -> anyhow::Result { + // Pre-hash with SHA-256 + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + let digest = hasher.finalize(); + let hex = hex::encode(digest); + let c = cost.unwrap_or(DEFAULT_COST as u32); + let hashed = hash(hex, c).context("bcrypt hash failed")?; + Ok(hashed) +} + +pub fn bcrypt_sha256_verify(password: &str, hashed: &str) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + let digest = hasher.finalize(); + let hex = hex::encode(digest); + let ok = verify(hex, hashed).context("bcrypt verify failed")?; + Ok(ok) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub exp: usize, +} + +pub fn create_access_token(config: &Config, subject: &str) -> anyhow::Result { + let exp = (Utc::now() + Duration::minutes(config.access_token_expire_minutes as i64)).timestamp() as usize; + let claims = Claims { sub: subject.to_string(), exp }; + let header = Header::new(Algorithm::HS256); + let token = encode(&header, &claims, &EncodingKey::from_secret(config.secret_key.as_bytes()))?; + Ok(token) +} + +pub fn verify_access_token(config: &Config, token: &str) -> anyhow::Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + let token_data = decode::(token, &DecodingKey::from_secret(config.secret_key.as_bytes()), &validation)?; + Ok(token_data.claims) +} diff --git a/src/db/entities/mod.rs b/src/db/entities/mod.rs new file mode 100644 index 0000000..bcf26bf --- /dev/null +++ b/src/db/entities/mod.rs @@ -0,0 +1,3 @@ +pub mod refresh_tokens; +pub mod users; + diff --git a/src/db/entities/refresh_tokens.rs b/src/db/entities/refresh_tokens.rs new file mode 100644 index 0000000..89aa9dd --- /dev/null +++ b/src/db/entities/refresh_tokens.rs @@ -0,0 +1,17 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "refresh_tokens")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub user_id: Uuid, + pub token: String, + pub created_at: DateTimeUtc, + pub expires_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/entities/users.rs b/src/db/entities/users.rs new file mode 100644 index 0000000..b66ea56 --- /dev/null +++ b/src/db/entities/users.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub email: String, + pub hashed_password: Option, + pub provider: Option, + pub is_active: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..69c6953 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,14 @@ +use crate::core::config::Config; +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; +use std::time::Duration; + +pub mod entities; + +pub async fn connect(cfg: &Config) -> anyhow::Result { + let mut opt = ConnectOptions::new(cfg.database_url.clone()); + opt.max_connections(10) + .min_connections(1) + .connect_timeout(Duration::from_secs(8)); + let db = Database::connect(opt).await?; + Ok(db) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..ffd70b8 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,30 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +#[allow(dead_code)] +pub enum AppError { + #[error("not found")] + NotFound, + #[error("unauthorized")] + Unauthorized, + #[error("bad request: {0}")] + BadRequest(String), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, msg) = match &self { + AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + AppError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + let body = json!({"error": msg}); + (status, axum::Json(body)).into_response() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ac3184c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,41 @@ +use axum::Router; +use dotenvy::dotenv; +use std::net::SocketAddr; + +mod api; +mod core; +mod db; +mod errors; +mod schemas; +mod services; +mod utils; + +use crate::core::config::Config; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv().ok(); + tracing_subscriber::fmt::init(); + + let config = Config::from_env()?; + + // Initialize DB + let db = db::connect(&config).await?; + + // Shared app state + let state = std::sync::Arc::new(crate::api::deps::AppState { db: db.clone(), cfg: config.clone() }); + + // Build router with /api/v1 prefix and provide state via Extension + let api_router = Router::new() + .merge(api::routers::auth::router()) + .merge(api::routers::users::router()); + + let app = Router::new().nest("/api/v1", api_router).layer(axum::Extension(state)); + + let addr = SocketAddr::from((config.server_host.parse::()?, config.server_port)); + tracing::info!("Starting server on {}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/src/schemas/mod.rs b/src/schemas/mod.rs new file mode 100644 index 0000000..b1789e8 --- /dev/null +++ b/src/schemas/mod.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Deserialize, Validate)] +pub struct RegisterRequest { + #[validate(email)] + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +#[derive(Debug, Serialize)] +#[allow(dead_code)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_in: i64, +} + +#[derive(Debug, Serialize)] +pub struct UserResponse { + pub id: String, + pub email: String, + pub provider: Option, +} diff --git a/src/services/auth_service.rs b/src/services/auth_service.rs new file mode 100644 index 0000000..2d84f32 --- /dev/null +++ b/src/services/auth_service.rs @@ -0,0 +1,62 @@ +use crate::core::config::Config; +use crate::core::security::{bcrypt_sha256_hash_password, bcrypt_sha256_verify, create_access_token}; +use crate::db::entities::{refresh_tokens, users}; +use sea_orm::{DatabaseConnection, EntityTrait, Set, QueryFilter, ColumnTrait}; +use uuid::Uuid; +use chrono::Utc; + +pub struct AuthService; + +impl AuthService { + pub async fn register(db: &DatabaseConnection, cfg: &Config, email: &str, password: &str) -> anyhow::Result<(String, String)> { + // hash password + let hashed = bcrypt_sha256_hash_password(password, None)?; + + // create user + let user_id = Uuid::new_v4(); + let user = users::ActiveModel { + id: Set(user_id), + email: Set(email.to_string()), + hashed_password: Set(Some(hashed)), + provider: Set(None), + is_active: Set(true), + created_at: Set(chrono::Utc::now()), + }; + let _ = users::Entity::insert(user).exec(db).await?; + + // create refresh token + let refresh_token = Uuid::new_v4().to_string(); + let rt = refresh_tokens::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + token: Set(refresh_token.clone()), + created_at: Set(Utc::now()), + expires_at: Set((Utc::now() + chrono::Duration::days(cfg.refresh_token_expire_days)).into()), + }; + let _ = refresh_tokens::Entity::insert(rt).exec(db).await?; + + let access = create_access_token(cfg, &user_id.to_string())?; + Ok((access, refresh_token)) + } + + pub async fn login(db: &DatabaseConnection, cfg: &Config, email: &str, password: &str) -> anyhow::Result<(String, String)> { + if let Some(user) = users::Entity::find().filter(users::Column::Email.eq(email.to_string())).one(db).await? { + if let Some(hp) = user.hashed_password.clone() { + if bcrypt_sha256_verify(password, &hp)? { + let access = create_access_token(cfg, &user.id.to_string())?; + let refresh_token = Uuid::new_v4().to_string(); + let rt = refresh_tokens::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + token: Set(refresh_token.clone()), + created_at: Set(Utc::now()), + expires_at: Set((Utc::now() + chrono::Duration::days(cfg.refresh_token_expire_days)).into()), + }; + let _ = refresh_tokens::Entity::insert(rt).exec(db).await?; + return Ok((access, refresh_token)); + } + } + } + anyhow::bail!("Invalid credentials") + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..67df926 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod auth_service; +pub mod user_service; + diff --git a/src/services/user_service.rs b/src/services/user_service.rs new file mode 100644 index 0000000..b8d68c1 --- /dev/null +++ b/src/services/user_service.rs @@ -0,0 +1,13 @@ +use crate::db::entities::users; +use sea_orm::{DatabaseConnection, EntityTrait}; + +#[allow(dead_code)] +pub struct UserService; + +impl UserService { + #[allow(dead_code)] + pub async fn get_by_id(db: &DatabaseConnection, id: uuid::Uuid) -> anyhow::Result> { + let user = users::Entity::find_by_id(id).one(db).await?; + Ok(user) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..83826ec --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,6 @@ +use chrono::{DateTime, Utc}; + +#[allow(dead_code)] +pub fn now_utc() -> DateTime { + Utc::now() +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..5511ed6 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,7 @@ +#[tokio::test] +#[ignore] +async fn register_and_get_me() { + // This integration test is a placeholder. It requires a running DB and server. + // Marked #[ignore] to avoid running by default. + assert!(true); +}