first commit
This commit is contained in:
12
.env
Normal file
12
.env
Normal file
@@ -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
|
||||||
11
.env.example
Normal file
11
.env.example
Normal file
@@ -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
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Generated for Rust projects
|
||||||
|
/target/
|
||||||
|
**/target/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Cargo
|
||||||
|
/Cargo.lock
|
||||||
396
API_DOCUMENTATION.md
Normal file
396
API_DOCUMENTATION.md
Normal file
@@ -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 <access_token>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <access_token>"
|
||||||
|
|
||||||
|
# 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 <access_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
@@ -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"] }
|
||||||
29
README.md
Normal file
29
README.md
Normal file
@@ -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.
|
||||||
138
account_system.md
Normal file
138
account_system.md
Normal file
@@ -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 <access_token>, 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.
|
||||||
19
migrations/initial.sql
Normal file
19
migrations/initial.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
9
src/api/deps.rs
Normal file
9
src/api/deps.rs
Normal file
@@ -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<AppState>;
|
||||||
3
src/api/mod.rs
Normal file
3
src/api/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod deps;
|
||||||
|
pub mod routers;
|
||||||
|
|
||||||
60
src/api/routers/auth.rs
Normal file
60
src/api/routers/auth.rs
Normal file
@@ -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<SharedState>, Json(payload): Json<RegisterRequest>) -> axum::response::Json<serde_json::Value> {
|
||||||
|
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<SharedState>, Json(payload): Json<LoginRequest>) -> axum::response::Json<serde_json::Value> {
|
||||||
|
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<String>, Extension(state): Extension<SharedState>) -> 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<std::collections::HashMap<String, String>>, Extension(state): Extension<SharedState>) -> axum::response::Json<serde_json::Value> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
3
src/api/routers/mod.rs
Normal file
3
src/api/routers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
29
src/api/routers/users.rs
Normal file
29
src/api/routers/users.rs
Normal file
@@ -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<SharedState>, req: axum::extract::Request) -> Json<UserResponse> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
26
src/bin/migrate.rs
Normal file
26
src/bin/migrate.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
35
src/core/config.rs
Normal file
35
src/core/config.rs
Normal file
@@ -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<Self> {
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/core/mod.rs
Normal file
4
src/core/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod security;
|
||||||
|
pub mod oauth;
|
||||||
|
|
||||||
117
src/core/oauth.rs
Normal file
117
src/core/oauth.rs
Normal file
@@ -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<String> {
|
||||||
|
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<OAuthUser> {
|
||||||
|
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<bool>
|
||||||
|
}
|
||||||
|
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<GitHubEmail> = 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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/core/security.rs
Normal file
48
src/core/security.rs
Normal file
@@ -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<u32>) -> anyhow::Result<String> {
|
||||||
|
// 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<bool> {
|
||||||
|
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<String> {
|
||||||
|
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<Claims> {
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
let token_data = decode::<Claims>(token, &DecodingKey::from_secret(config.secret_key.as_bytes()), &validation)?;
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
3
src/db/entities/mod.rs
Normal file
3
src/db/entities/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod refresh_tokens;
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
17
src/db/entities/refresh_tokens.rs
Normal file
17
src/db/entities/refresh_tokens.rs
Normal file
@@ -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 {}
|
||||||
18
src/db/entities/users.rs
Normal file
18
src/db/entities/users.rs
Normal file
@@ -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<String>,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
14
src/db/mod.rs
Normal file
14
src/db/mod.rs
Normal file
@@ -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<DatabaseConnection> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
30
src/errors.rs
Normal file
30
src/errors.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -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::<std::net::IpAddr>()?, config.server_port));
|
||||||
|
tracing::info!("Starting server on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
37
src/schemas/mod.rs
Normal file
37
src/schemas/mod.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
62
src/services/auth_service.rs
Normal file
62
src/services/auth_service.rs
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/services/mod.rs
Normal file
3
src/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod auth_service;
|
||||||
|
pub mod user_service;
|
||||||
|
|
||||||
13
src/services/user_service.rs
Normal file
13
src/services/user_service.rs
Normal file
@@ -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<Option<users::Model>> {
|
||||||
|
let user = users::Entity::find_by_id(id).one(db).await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/utils.rs
Normal file
6
src/utils.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn now_utc() -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
7
tests/integration_tests.rs
Normal file
7
tests/integration_tests.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user