From 8fd223425a5db8e378774ce93883da377849fc10 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Sun, 26 Jul 2026 22:25:18 +0100 Subject: [PATCH 01/18] chore: remove v1 codebase to start v2 rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes examples/, internal/, go.mod, go.sum, cryden.go, CHANGE-LOG.md, CODE-OF-CONDUCT.md, and CONTRIBUTING.md β€” full rewrite on this branch, none of the old code or docs carry forward. CODE-OF-CONDUCT.md and CONTRIBUTING.md will be re-added separately once updated for the new architecture. --- CHANGE-LOG.md | 55 --- CODE-OF-CONDUCT.md | 55 --- CONTRIBUTING.md | 232 --------- README.md | 558 ---------------------- cryden.go | 224 --------- examples/basic/main.go | 29 -- examples/complete/main.go | 181 ------- go.mod | 23 - go.sum | 56 --- internal/core/audit.go | 174 ------- internal/core/auth.go | 281 ----------- internal/core/device.go | 92 ---- internal/core/engine.go | 93 ---- internal/core/errors.go | 44 -- internal/core/hasher.go | 48 -- internal/core/helpers.go | 40 -- internal/core/interfaces.go | 37 -- internal/core/models.go | 58 --- internal/core/rate_limiter.go | 117 ----- internal/core/session.go | 62 --- internal/core/user.go | 119 ----- internal/core/validations.go | 154 ------ internal/stores/memory/session_store.go | 180 ------- internal/stores/memory/user_store.go | 139 ------ internal/stores/mongodb/session_store.go | 236 --------- internal/stores/mongodb/user_store.go | 175 ------- internal/stores/postgres/session_store.go | 190 -------- internal/stores/postgres/user_store.go | 198 -------- internal/stores/sqlite/session_store.go | 190 -------- internal/stores/sqlite/user_store.go | 199 -------- internal/tests/audit_file_test.go | 232 --------- internal/tests/audit_test.go | 69 --- internal/tests/device_test.go | 122 ----- internal/tests/engine_test.go | 482 ------------------- internal/tests/hasher_test.go | 51 -- internal/tests/jwt_test.go | 43 -- internal/tests/rate_limiter_test.go | 102 ---- internal/tests/store_test.go | 204 -------- internal/tests/token_hashing_test.go | 218 --------- internal/tests/user_store_test.go | 152 ------ internal/token/service.go | 64 --- 41 files changed, 5978 deletions(-) delete mode 100644 CHANGE-LOG.md delete mode 100644 CODE-OF-CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 README.md delete mode 100644 cryden.go delete mode 100644 examples/basic/main.go delete mode 100644 examples/complete/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/core/audit.go delete mode 100644 internal/core/auth.go delete mode 100644 internal/core/device.go delete mode 100644 internal/core/engine.go delete mode 100644 internal/core/errors.go delete mode 100644 internal/core/hasher.go delete mode 100644 internal/core/helpers.go delete mode 100644 internal/core/interfaces.go delete mode 100644 internal/core/models.go delete mode 100644 internal/core/rate_limiter.go delete mode 100644 internal/core/session.go delete mode 100644 internal/core/user.go delete mode 100644 internal/core/validations.go delete mode 100644 internal/stores/memory/session_store.go delete mode 100644 internal/stores/memory/user_store.go delete mode 100644 internal/stores/mongodb/session_store.go delete mode 100644 internal/stores/mongodb/user_store.go delete mode 100644 internal/stores/postgres/session_store.go delete mode 100644 internal/stores/postgres/user_store.go delete mode 100644 internal/stores/sqlite/session_store.go delete mode 100644 internal/stores/sqlite/user_store.go delete mode 100644 internal/tests/audit_file_test.go delete mode 100644 internal/tests/audit_test.go delete mode 100644 internal/tests/device_test.go delete mode 100644 internal/tests/engine_test.go delete mode 100644 internal/tests/hasher_test.go delete mode 100644 internal/tests/jwt_test.go delete mode 100644 internal/tests/rate_limiter_test.go delete mode 100644 internal/tests/store_test.go delete mode 100644 internal/tests/token_hashing_test.go delete mode 100644 internal/tests/user_store_test.go delete mode 100644 internal/token/service.go diff --git a/CHANGE-LOG.md b/CHANGE-LOG.md deleted file mode 100644 index f8b64cf..0000000 --- a/CHANGE-LOG.md +++ /dev/null @@ -1,55 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/), -and this project adheres to [Semantic Versioning](https://semver.org/). - -## [1.0.0] - 2026-03-08 - -### πŸš€ Initial Release - -#### Added -- Core authentication engine -- Email/password signup and login -- JWT access token generation and verification -- Opaque refresh tokens with database storage -- Rate limiting with memory implementation -- Audit logging with console output -- Session management (create, revoke, list) -- Multiple storage backends: - - In-memory (testing) - - SQLite (offline-first) - - PostgreSQL (production) - - MongoDB (document stores) -- Complete test suite with 90%+ coverage -- Examples for all features - -#### Features -- `SignUp` - Create new user accounts -- `Login` - Authenticate and get tokens -- `Logout` - Revoke current session -- `LogoutAll` - Revoke all user sessions -- `ChangePassword` - Update with old password verification -- `ChangeEmail` - Update user email -- `DeleteAccount` - Remove user and all sessions -- `RefreshToken` - Get new tokens with rotation -- `VerifyToken` - Validate JWT and get user ID - -#### Developer Experience -- Clean public API at `cryden/` -- Interface-driven design for easy mocking -- In-memory stores for fast tests -- Comprehensive documentation -- Working examples - -#### Documentation -- Getting started guide -- Philosophy and design decisions -- Architecture overview -- API reference -- Testing guide -- Contribution guidelines - -[1.0.0]: https://github.com/crydensync/cryden/releases/tag/v1.0.0 - diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md deleted file mode 100644 index 3ace435..0000000 --- a/CODE-OF-CONDUCT.md +++ /dev/null @@ -1,55 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes -* Focusing on what is best for the overall community - -Examples of unacceptable behavior: - -* The use of sexualized language or imagery, and sexual attention or advances -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information without explicit permission -* Other conduct which could reasonably be considered inappropriate - -## Enforcement Responsibilities - -Project maintainers are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior they deem inappropriate, threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the project team at conduct@cryden.dev. All complaints will be -reviewed and investigated promptly and fairly. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 59b0327..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,232 +0,0 @@ -# Contributing to CrydenSync - -First off, thank you for considering contributing to Cryden! πŸŽ‰ - -## Code of Conduct - -This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. - -## 🌍 Our Philosophy - -Cryden is built for developers worldwide, with special consideration for: -- **Offline-first development** β€” Works without internet -- **Low-bandwidth environments** β€” Small binary size -- **Developer ownership** β€” Your users belong to you -- **Simplicity** β€” Easy to understand and extend - -## πŸš€ Getting Started - -### Prerequisites -- Go 1.21 or higher -- Git -- Make (optional) - -### Development Setup - -1. Fork the repository -2. Clone your fork: - ```bash - git clone https://github.com/your-username/cryden.git - cd cryden -``` - -1. Install dependencies: - ```bash - go mod download - ``` -2. Run tests: - ```bash - go test ./internal/tests/... -v - ``` -3. Run the example: - ```bash - cd examples/complete - go run main.go - ``` - -πŸ§ͺ Testing Guidelines - -Write Tests First - -We practice test-driven development where possible: - -```go -func TestNewFeature(t *testing.T) { - // 1. Setup - engine := cryden.New() - - // 2. Test the feature - result, err := engine.NewFeature() - - // 3. Assert - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if result == nil { - t.Error("Expected result, got nil") - } -} -``` - -Test Coverage - -Β· Aim for 80%+ coverage -Β· Use go test -cover to check -Β· New features must include tests - -Mocking - -Use interfaces for mocking: - -```go -type MockUserStore struct { - users map[string]*User -} - -func (m *MockUserStore) GetByEmail(email string) (*User, error) { - if user, ok := m.users[email]; ok { - return user, nil - } - return nil, ErrUserNotFound -} -``` - -πŸ’… Coding Standards - -Go Style - -Β· Follow Go Code Review Comments -Β· Run go fmt before committing -Β· Use golangci-lint for additional checks - -Naming Conventions - -Β· Interfaces: UserStore, Hasher, AuditLogger -Β· Methods: SignUp, Login, LogoutAll -Β· Errors: ErrUserNotFound, ErrInvalidToken -Β· Tests: TestLogin, TestChangePassword/success - -Documentation - -Β· All exported functions must have comments -Β· Examples should be runnable -Β· Update relevant docs when adding features - -πŸ”§ Pull Request Process - -1. Create an issue first for discussion -2. Fork and branch (feature/your-feature or fix/your-fix) -3. Write code with tests -4. Run tests locally: - ```bash - make test - ``` -5. Update documentation if needed -6. Push and create PR -7. Address review comments - -PR Checklist - -Β· Tests pass -Β· Code is formatted -Β· Documentation updated -Β· Commit messages clear -Β· No unrelated changes - -πŸ“ Commit Messages - -Follow Conventional Commits: - -``` -feat: add logout all devices -^──^ ^─────────────────^ -| | -| └─ Description in imperative mood -| -└─ Type: feat, fix, docs, style, refactor, test, chore -``` - -Examples: - -Β· feat: add rate limiting headers -Β· fix: handle nil session in logout -Β· docs: update getting started guide -Β· test: add mfa test cases -Β· refactor: extract token generation - -πŸš€ Release Process - -Maintainers follow: - -1. Update version in go.mod -2. Update CHANGELOG.md -3. Create git tag: git tag v1.0.0 -4. Push tag: git push origin v1.0.0 -5. GitHub Actions creates release - -πŸ“š Documentation Contributions - -Markdown Style - -Β· One sentence per line (easier diffs) -Β· Use code blocks with language -Β· Include working examples -Β· Link to related docs - -Adding Examples - -Each example should: - -Β· Be in examples/ directory -Β· Have a main.go that runs -Β· Include comments -Β· Be referenced in docs - -πŸ› Reporting Bugs - -Include: - -Β· Cryden version -Β· Go version -Β· Database being used -Β· Minimal reproduction code -Β· Expected vs actual behavior - -πŸ’‘ Feature Requests - -Tell us: - -Β· What problem you're solving -Β· How it fits Cryden's philosophy -Β· Example usage -Β· Why existing solutions don't work - -🌟 Recognition - -Contributors will be: - -Β· Listed in CONTRIBUTORS.md -Β· Mentioned in release notes -Β· Thanked in community spaces - -πŸ“ž Getting Help - -Β· Issues: GitHub issues -Β· Discussions: GitHub Discussions -Β· Twitter: @crydensync -Β· Email: contributors@cryden.dev - -πŸŽ‰ Thank You! - -Your contributions make CrydenSync better for everyone, especially developers in: - -Β· 🌍 Africa (offline-first, low bandwidth) -Β· πŸš€ Startups (no vendor lock-in) -Β· πŸ§‘β€πŸ’» Indie developers (free, open source) - ---- - -
- Made with ❀️ by the CrydenSync community -
-``` diff --git a/README.md b/README.md deleted file mode 100644 index e7e34f9..0000000 --- a/README.md +++ /dev/null @@ -1,558 +0,0 @@ -# CrydenSync πŸ” - -
- -**Embeddable authentication engine for Go β€” offline-first, framework-agnostic.** - -[![Go Reference](https://pkg.go.dev/badge/github.com/crydensync/cryden.svg)](https://pkg.go.dev/github.com/crydensync/cryden) -[![Go Report Card](https://goreportcard.com/badge/github.com/crydensync/cryden)](https://goreportcard.com/report/github.com/crydensync/cryden) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - - -[![GitHub Stars](https://img.shields.io/github/stars/crydensync/cryden?style=social)](https://github.com/crydensync/cryden/stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/crydensync/cryden?style=social)](https://github.com/crydensync/cryden/network/members) -[![GitHub Watchers](https://img.shields.io/github/watchers/crydensync/cryden?style=social)](https://github.com/crydensync/cryden/watchers) -[![GitHub Downloads](https://img.shields.io/github/downloads/crydensync/cryden/total)](https://github.com/crydensync/cryden/releases) - - -
-## 🎯 The Problem - -Authentication is not business logic, yet every project rewrites it. Developers face three painful choices: - -1. **Rewrite auth logic** for every project β€” risky, inconsistent, time-consuming -2. **Use hosted auth services** β€” vendor lock-in, users aren't yours, requires internet -3. **Use framework-specific tools** β€” tied to Express, Django, Next.js β€” not reusable - -## πŸ’‘ The Solution - -CrydenSync is an **embeddable authentication engine** that gives you a standard, reusable auth system you control: - -```go -package main - -import ( - "context" - "fmt" - "log" - - "github.com/crydensync/cryden" -) - -func main() { - // Create context - ctx := context.Background() - - // 1. Create engine (in-memory storage - perfect for testing) - engine := cryden.New() - fmt.Println("βœ… Engine created") - - // 2. Sign up a new user - email := "alice@example.com" - password := "SecurePass123" - - user, err := cryden.SignUp(ctx, engine, email, password) - if err != nil { - log.Fatalf("❌ SignUp failed: %v", err) - } - fmt.Printf("βœ… User created: %s (%s)\n", user.ID, user.Email) - - // 3. Login - tokens, rateLimit, err := cryden.Login(ctx, engine, email, password) - if err != nil { - log.Fatalf("❌ Login failed: %v", err) - } - fmt.Printf("βœ… Login successful!\n") - fmt.Printf(" Access Token: %s...\n", tokens.AccessToken[:50]) - fmt.Printf(" Refresh Token: %s...\n", tokens.RefreshToken[:50]) - fmt.Printf(" Rate Limit Remaining: %d\n", rateLimit.Remaining) - - // 4. Verify token - userID, err := cryden.VerifyToken(engine, tokens.AccessToken) - if err != nil { - log.Fatalf("❌ Token verification failed: %v", err) - } - fmt.Printf("βœ… Token verified for user: %s\n", userID) - - // 5. Logout - err = cryden.Logout(ctx, engine, tokens.RefreshToken) - if err != nil { - log.Fatalf("❌ Logout failed: %v", err) - } - fmt.Println("βœ… Logout successful") - - // 6. Try to use logged out token (should fail) - _, err = cryden.RefreshToken(ctx, engine, tokens.RefreshToken) - if err != nil { - fmt.Printf("βœ… Expected error after logout: %v\n", err) - } - - fmt.Println("\nπŸŽ‰ All tests passed!") -} - -``` -[View full example β†’](examples/complete/main.go) - -✨ Features - -βœ… v1.0.0 (Current) - -Β· Email/password authentication β€” Secure, bcrypt hashed -Β· JWT access tokens β€” Short-lived, stateless -Β· Opaque refresh tokens β€” Stored in DB for revocation -Β· Rate limiting β€” Per IP with headers (X-RateLimit-*) -Β· Audit logging β€” Track every auth event -Β· Session management β€” Logout single device or all devices -Β· Multiple storage backends β€” Memory, SQLite, PostgreSQL, MongoDB -Β· Complete test suite β€” 90%+ coverage -Β· Offline-first β€” Works without internet, SQLite by default - -🚧 Coming Soon - -Feature Status Target -gRPC API 🚧 Planned v1.1.0 -CLI tool (csax) 🚧 Planned v1.1.0 -Language SDKs (JS, Python, PHP) 🚧 Planned v1.2.0 -MFA/2FA (TOTP) πŸ“… Future v1.3.0 -Magic Links πŸ“… Future v1.3.0 -WebAuthn/Passkeys πŸ“… Future v2.0.0 - -πŸ“¦ Installation - -```bash -go get github.com/crydensync/cryden@v1.0.0 -``` - -```markdown -## πŸ§ͺ Local Development - -Want to hack on CrydenSync itself? Use it locally in your own app: - -```bash -git clone https://github.com/crydensync/cryden.git -cd your-app -go mod edit -replace github.com/crydensync/cryden=../cryden -go run main.go # Uses your local version! -``` - -πŸ“š Full Local Dev Guide β†’ (CrydenSync web docs soon) - - -πŸ“– Documentation - -Section Description -πŸ“š Getting Started 60-second working auth -🎯 Philosophy Why Cryden exists -πŸ—οΈ Architecture How it works -πŸ“ Design Decisions Why we built it this way -πŸ”§ Guide Installation, config, middleware, testing -πŸ”Œ Adapters Interface implementations -πŸ“˜ API Reference Complete API docs -πŸ’‘ Examples Copy-paste working code - -πŸ§ͺ Testing - -CrydenSync is designed for maximum testability: - -```go -func TestLogin(t *testing.T) { - engine := cryden.New() // In-memory storage - - // Optional: Use mock hasher for faster tests - engine.WithHasher(&core.MockHasher{}) - - // Optional: Disable rate limiting - engine.WithRateLimiter(&core.NoopRateLimiter{}) - - ctx := context.Background() - cryden.SignUp(ctx, engine, "test@example.com", "pass") - tokens, _, err := cryden.Login(ctx, engine, "test@example.com", "pass") - - assert.NoError(t, err) - assert.NotEmpty(t, tokens.AccessToken) -} -``` - -πŸ“– Testing Guide β†’ - -πŸ”§ Configuration - -```go -// With SQLite persistence -engine, err := cryden.WithSQLite("users.db") - -// With custom JWT secret (required in production) -cryden.WithJWTSecret(engine, os.Getenv("JWT_SECRET")) - -// With custom rate limiter -engine.WithRateLimiter(redis.NewRateLimiter()) - -// With custom audit logger -engine.WithAuditLogger(file.NewAuditLogger("auth.log")) -``` - -πŸ“Š Storage Backends - -Backend Status Use Case -Memory βœ… Stable Testing -SQLite βœ… Stable Offline-first, development -PostgreSQL βœ… Stable Production -MongoDB βœ… Stable Document stores -MySQL 🚧 Planned v1.1.0 -Redis 🚧 Planned v1.1.0 (rate limiting) - -## πŸ“› About the Name - -**CrydenSync** is the full name of the project, but the Go package is simply `cryden` for brevity. - -```go -import "github.com/crydensync/cryden" // Notice: crydensync/cryden - -auth := cryden.New() // Short and sweet! -`````` - -βœ… Perfect! Let's add a "How It Works" section to your README.md - -Add this after Features: - -```markdown -## πŸ”§ How CrydenSync Works (Under the Hood) - -### The Authentication Flow - -When a user logs in, here's what happens: - -```mermaid -sequenceDiagram - participant App as Your App - participant Engine as Cryden Engine - participant Hasher as Password Hasher - participant Store as Database Store - participant Logger as Audit Logger - participant Limiter as Rate Limiter - - App->>Engine: Login(email, password) - Engine->>Limiter: Check rate limit - Limiter-->>Engine: βœ… Allowed (remaining: 4) - - Engine->>Store: GetUserByEmail(email) - Store-->>Engine: User (with hashed password) - - Engine->>Hasher: Compare(password, hash) - Hasher-->>Engine: βœ… Match - - Engine->>Store: CreateSession(userID) - Store-->>Engine: Session (with refresh token) - - Engine->>Engine: Generate JWT access token - - Engine->>Logger: Log successful login - - Engine-->>App: TokenPair + RateLimit info -``` - -### The Dual-Token System - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ CLIENT SIDE β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Access Token (JWT) β”‚ Refresh Token (Opaque) β”‚ -β”‚ β€’ Short-lived (15m) β”‚ β€’ Long-lived (7d) β”‚ -β”‚ β€’ Stateless β”‚ β€’ Stored in database β”‚ -β”‚ β€’ Contains user ID β”‚ β€’ Can be revoked β”‚ -β”‚ β€’ No DB lookup β”‚ β€’ Supports "logout all" β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### Why This Design? - -#### JWT for Speed -```go -// API can verify without database lookup -claims, _ := cryden.VerifyToken(token) -userID := claims.UserID // Fast! -``` - -#### Opaque Tokens for Control -```go -// Logout all devices = delete all refresh tokens -cryden.LogoutAll(ctx, engine, userID) // Instant revocation -``` - -### The Interface Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ YOUR APPLICATION β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ cryden.New() β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ β”‚ CRYDEN ENGINE β”‚ -β”‚ β”‚ β€’ SignUp, Login, Logout β”‚ -β”‚ β”‚ β€’ Token generation & validation β”‚ -β”‚ β”‚ β€’ Session management β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ INTERFACES β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ UserStore β”‚ SessionStore β”‚ Hasher β”‚ -β”‚ β€’ Create β”‚ β€’ Create β”‚ β€’ Compare β”‚ -β”‚ β€’ GetByEmailβ”‚ β€’ GetByToken β”‚ β€’ Hash β”‚ -β”‚ β€’ Update β”‚ β€’ Revoke β”‚ β”‚ -β”‚ β€’ Delete β”‚ β€’ RevokeAll β”‚ β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ RateLimiter β”‚ AuditLogger β”‚ (More adapters...) β”‚ -β”‚ β€’ Allow β”‚ β€’ Log β”‚ β”‚ -β”‚ β€’ Reset β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### Storage Adapters in Action - -```go -// Same code works with ANY database! -type UserStore interface { - GetByEmail(email string) (*User, error) - Create(user *User) error - // ... -} - -// Memory adapter (testing) -type MemoryUserStore struct { - users map[string]*User -} - -// SQLite adapter (offline) -type SQLiteUserStore struct { - db *sql.DB -} - -// PostgreSQL adapter (production) -type PostgresUserStore struct { - db *sql.DB -} - -// MongoDB adapter (NoSQL) -type MongoUserStore struct { - coll *mongo.Collection -} -``` - -### The Audit Trail - -Every action is logged for security: - -```json -{ - "timestamp": "2026-03-10T10:30:00Z", - "user_id": "usr_123", - "action": "SIGN_IN_SUCCESS", - "ip_address": "192.168.1.100", - "user_agent": "Mozilla/5.0...", // comming soon - "status": "SUCCESS" -} -``` - -### Rate Limiting with Headers - -```http -HTTP/1.1 200 OK -X-RateLimit-Limit: 5 -X-RateLimit-Remaining: 3 -X-RateLimit-Reset: 45 -``` - -Frontend can show: "3 attempts remaining. Try again in 45 seconds." - -### Session Management -# **Planed for v1.1.0 - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ USER SESSIONS β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Device: iPhone 15 β”‚ -β”‚ Location: Lagos, Nigeria β”‚ -β”‚ Last active: 2 minutes ago β”‚ -β”‚ Status: ● Active β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Device: MacBook Pro β”‚ -β”‚ Location: Lagos, Nigeria β”‚ -β”‚ Last active: 2 hours ago β”‚ -β”‚ Status: ● Active β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - [Logout All Devices] -``` - -### Security Layers - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ SECURITY LAYERS β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Layer 1: Rate Limiting β”‚ -β”‚ β†’ Prevents brute force attacks β”‚ -β”‚ β†’ 5 attempts per minute per IP β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Layer 2: Password Hashing β”‚ -β”‚ β†’ bcrypt with salt β”‚ -β”‚ β†’ Argon2id coming in v1.1 β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Layer 3: JWT Signing β”‚ -β”‚ β†’ HMAC-SHA256 with secret β”‚ -β”‚ β†’ Short expiration (15m) β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Layer 4: Refresh Token Rotation β”‚ -β”‚ β†’ New token on every refresh β”‚ -β”‚ β†’ Old tokens revoked immediately β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ Layer 5: Audit Logging β”‚ -β”‚ β†’ Every action tracked β”‚ -β”‚ β†’ Suspicious activity detection (future) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### The Complete Request Lifecycle - -``` -1. Request arrives - ↓ -2. Rate Limiter checks IP - ↓ -3. User credentials validated - ↓ -4. Password compared (constant time) - ↓ -5. Session created in database - ↓ -6. JWT access token generated - ↓ -7. Audit log entry created - ↓ -8. Response with tokens + rate limit headers - ↓ -9. Frontend stores tokens securely -``` - -### Why This Matters for Your Users - -```go -// Your users get: -// βœ… Security (bcrypt, rate limiting) -// βœ… Control (logout all devices) -// βœ… Visibility (audit logs, session list) -// βœ… Flexibility (any database) -// βœ… Freedom (no vendor lock-in) -``` - -## 🎯 The Bottom Line - -CrydenSync isn't just an auth library β€” it's a **complete authentication infrastructure** that you control completely. - -- **You own the data** -- **You choose the database** -- **You control the security** -- **You keep your users** - -No vendor lock-in. No hidden costs. Just auth that works everywhere. - -## πŸ”’ Security Notes v1.0.0 - -### βœ… Implemented -- Password hashing with bcrypt -- JWT signing with HMAC-SHA256 -- Rate limiting to prevent brute force -- Audit logging for all auth events - -### ⚠️ Planned for v1.1.0 -- Refresh token hashing in database -- Session token hashing -- Device fingerprinting -- Argon2id hasher option - -### Future Security Enhancements -- Email verification (v1.1) -- Password reset flow (v1.1) -- MFA/2FA (v1.2) -- Login notifications (v1.2) -- Breached password detection (v1.2) - -### πŸ” Best Practices -1. Always use HTTPS in production -2. Set strong JWT secrets via environment variables -3. Monitor audit logs for suspicious activity -4. Add email verification before sensitive actions - -🀝 Contributing - -We welcome contributions! See CONTRIBUTING.md for: - -Β· Code of Conduct -Β· Development setup -Β· Pull request process -Β· Coding standards - -πŸ“„ License - -MIT Β© Crydensync - -⭐ Support - -If you find Cryden useful, please star the repo! - -## πŸ“Š Project Stats - -| Metric | Value | -|--------|-------| -| ⭐ Stars | [![Stars](https://img.shields.io/github/stars/crydensync/cryden?style=flat&logo=github)](https://github.com/crydensync/cryden/stargazers) | -| 🍴 Forks | [![Forks](https://img.shields.io/github/forks/crydensync/cryden?style=flat&logo=github)](https://github.com/crydensync/cryden/network/members) | -| πŸ‘€ Watchers | [![Watchers](https://img.shields.io/github/watchers/crydensync/cryden?style=flat&logo=github)](https://github.com/crydensync/cryden/watchers) | -| πŸ“₯ Downloads | [![Downloads](https://img.shields.io/github/downloads/crydensync/cryden/total?style=flat&logo=github)](https://github.com/crydensync/cryden/releases) | -| 🏷️ Version | [![Version](https://img.shields.io/github/v/release/crydensync/cryden?style=flat&logo=github)](https://github.com/crydensync/cryden/releases) | -| βœ… Build | [![Build](https://github.com/crydensync/cryden/actions/workflows/test.yml/badge.svg)](https://github.com/crydensync/cryden/actions/workflows/test.yml) | -| πŸ“š Docs | [![Go Reference](https://pkg.go.dev/badge/github.com/crydensync/cryden.svg)](https://pkg.go.dev/github.com/crydensync/cryden) | -| πŸ“¦ Go Version | [![Go Version](https://img.shields.io/github/go-mod/go-version/crydensync/cryden)](https://golang.org) | -| πŸ“„ License | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) | - ---- - -## πŸ—ΊοΈ Roadmap - -### Current: v1.0.0 (March 2026) -βœ… Core authentication with email/password. βœ… JWT + refresh tokens. βœ… Rate limiting & audit logs. βœ… Multiple databases (SQLite, PostgreSQL, MongoDB) - -### Coming in v1.1.0 (Q2 2026) -πŸš€ CLI tool (`csax`) -πŸ“± Device tracking (IP, user agent, last seen) -πŸ” Argon2id hasher -⚑ Redis rate limiter -le audit logger -🐬 MySQL support - -### Coming in v1.2.0 (Q3 2026) -πŸ”Œ gRPC API -🌐 Language SDKs (JS, Python, PHP) -πŸ”” Webhooks -πŸ”„ Migration tools (Clerk, Auth0, Supabase) - -### Coming in v1.3.0 (Q4 2026) -πŸ” Multi-Factor Authentication (TOTP) -πŸ“§ Magic links & passwordless -πŸ”‘ WebAuthn / Passkeys -🌍 Social login (OAuth2) - -### Future (2027+) -☁️ Optional cloud sync -πŸ“Š Enterprise features -πŸ”Œ More adapters -πŸš€ v2.0.0 (breaking changes if needed) - -[View full roadmap β†’](docs/roadmap.md) - ---- - -
- Built with ❀️ in Africa · Own your users, not vendor lock-in -
diff --git a/cryden.go b/cryden.go deleted file mode 100644 index 1cb9bc9..0000000 --- a/cryden.go +++ /dev/null @@ -1,224 +0,0 @@ -// Package cryden is the main entry point for the CrydennSync authentication engine. -package cryden - -import ( - "context" - "database/sql" - "fmt" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" - "github.com/crydensync/cryden/internal/stores/sqlite" - "github.com/crydensync/cryden/internal/stores/mongodb" - "github.com/crydensync/cryden/internal/stores/postgres" -) - -// Engine is the main authentication engine. -type Engine = core.Engine - -// New creates an in-memory engine (perfect for testing) -func New() *Engine { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - return core.New(userStore, sessionStore) -} - -// WithSQLite creates an engine with persistent SQLite storage -func WithSQLite(dbPath string) (*Engine, error) { - // Create user store (which creates/migrates DB) - userStore, err := sqlite.NewUserStore(dbPath) - if err != nil { - return nil, err - } - - // Get the DB connection from user store - // You'll need to add this method to UserStore - type dbGetter interface { - GetDB() *sql.DB - } - getter, ok := userStore.(dbGetter) - if !ok { - return nil, fmt.Errorf("user store does not expose DB connection") - } - db := getter.GetDB() - - // Create session store with same DB - sessionStore := sqlite.NewSessionStore(db) - - return core.New(userStore, sessionStore), nil -} - -// WithMongoDB creates an engine with MongoDB storage -func WithMongoDB(uri, dbName string) (*Engine, error) { - userStore, err := mongodb.NewUserStore(uri, dbName) - if err != nil { - return nil, err - } - - sessionStore, err := mongodb.NewSessionStore(uri, dbName) - if err != nil { - return nil, err - } - - return core.New(userStore, sessionStore), nil -} - -// WithPostgreSQL creates an engine with PostgreSQL storage -func WithPostgreSQL(connStr string) (*Engine, error) { - userStore, err := postgres.NewUserStore(connStr) - if err != nil { - return nil, err - } - - // Get the DB connection from user store to reuse - db := userStore.GetDB() // You'll need to add this method - - sessionStore := postgres.NewSessionStore(db) - - return core.New(userStore, sessionStore), nil -} - -// WithFileAuditLogger sets a file-based audit logger -func WithFileAuditLogger(engine *Engine, filePath string) (*Engine, error) { - logger, err := core.NewFileAuditLogger(filePath) - if err != nil { - return nil, err - } - return engine.WithAuditLogger(logger), nil -} - -// ==================== AUTHENTICATION FLOWS ==================== - -// SignUp creates a new user account -func SignUp(ctx context.Context, engine *Engine, email, password string) (*User, error) { - return engine.SignUp(ctx, email, password) -} - -// Login authenticates a user and returns tokens -func Login(ctx context.Context, engine *Engine, email, password string, userAgent, ipAddress string) (*TokenPair, *LimitResult, error) { - deviceInfo := core.ParseUserAgent(userAgent) - return engine.Login(ctx, email, password, deviceInfo, ipAddress) -} - -// Logout revokes the current session -func Logout(ctx context.Context, engine *Engine, refreshToken string) error { - return engine.Logout(ctx, refreshToken) -} - -// LogoutAll revokes ALL sessions for a user -func LogoutAll(ctx context.Context, engine *Engine, userID string) error { - return engine.LogoutAll(ctx, userID) -} - -// ChangePassword updates user's password and logs out all devices -func ChangePassword(ctx context.Context, engine *Engine, userID, oldPassword, newPassword string) error { - return engine.ChangePassword(ctx, userID, oldPassword, newPassword) -} - -// ChangeEmail updates user's email -func ChangeEmail(ctx context.Context, engine *Engine, userID, newEmail string) error { - return engine.ChangeEmail(ctx, userID, newEmail) -} - -// DeleteAccount removes user and all sessions -func DeleteAccount(ctx context.Context, engine *Engine, userID string) error { - return engine.DeleteAccount(ctx, userID) -} - -// RefreshToken issues new tokens and rotates the refresh token -func RefreshToken(ctx context.Context, engine *Engine, refreshToken string) (*TokenPair, error) { - return engine.RefreshToken(ctx, refreshToken) -} - -// VerifyToken validates a JWT access token and returns the user ID -func VerifyToken(engine *Engine, tokenString string) (string, error) { - claims, err := engine.VerifyToken(tokenString) - if err != nil { - return "", err - } - return claims.UserID, nil -} - -// LoginWithDevice is a convenience method that extracts device from context -func LoginWithDevice(ctx context.Context, engine *Engine, email, password string, userAgent, ipAddress string) (*TokenPair, *LimitResult, error) { - return Login(ctx, engine, email, password, userAgent, ipAddress) -} - -// ==================== USER MANAGEMENT ==================== - -// GetUser retrieves a user by ID -func GetUser(ctx context.Context, engine *Engine, userID string) (*User, error) { - return engine.GetUser(ctx, userID) -} - -// GetUserByEmail retrieves a user by email -func GetUserByEmail(ctx context.Context, engine *Engine, email string) (*User, error) { - return engine.GetUserByEmail(ctx, email) -} - -// ==================== SESSION MANAGEMENT ==================== - -// ListSessions returns all active sessions for a user -func ListSessions(ctx context.Context, engine *Engine, userID string) ([]Session, error) { - return engine.ListSessions(ctx, userID) -} - -// RevokeSession manually revokes a specific session -func RevokeSession(ctx context.Context, engine *Engine, sessionID string) error { - return engine.RevokeSession(ctx, sessionID) -} - -// ==================== CONFIGURATION ==================== - -// WithJWTSecret sets a custom JWT secret -func WithJWTSecret(engine *Engine, secret string) *Engine { - return engine.WithJWTSecret(secret) -} - -// WithRateLimiter sets a custom rate limiter -func WithRateLimiter(engine *Engine, limiter RateLimiter) *Engine { - return engine.WithRateLimiter(limiter) -} - -// WithAuditLogger sets a custom audit logger -func WithAuditLogger(engine *Engine, logger AuditLogger) *Engine { - return engine.WithAuditLogger(logger) -} - -// WithHasher sets a custom password hasher -func WithHasher(engine *Engine, hasher Hasher) *Engine { - return engine.WithHasher(hasher) -} - -// ==================== RE-EXPORTED TYPES ==================== - -type User = core.User -type Session = core.Session -type TokenPair = core.TokenPair -type LimitResult = core.LimitResult -type Claims = core.Claims - -// Interfaces -type UserStore = core.UserStore -type SessionStore = core.SessionStore -type Hasher = core.Hasher -type RateLimiter = core.RateLimiter -type AuditLogger = core.AuditLogger -type AuditEntry = core.AuditEntry - -// ==================== RE-EXPORTED ERRORS ==================== - -var ( - ErrUserExists = core.ErrUserExists - ErrUserNotFound = core.ErrUserNotFound - ErrInvalidCredentials = core.ErrInvalidCredentials - ErrInvalidEmail = core.ErrInvalidEmail - ErrPasswordTooShort = core.ErrPasswordTooShort - ErrPasswordTooLong = core.ErrPasswordTooLong - ErrPasswordNoUpper = core.ErrPasswordNoUpper - ErrPasswordNoLower = core.ErrPasswordNoLower - ErrPasswordNoNumber = core.ErrPasswordNoNumber - ErrTooManyAttempts = core.ErrTooManyAttempts - ErrInvalidToken = core.ErrInvalidToken - ErrSessionNotFound = core.ErrSessionNotFound -) diff --git a/examples/basic/main.go b/examples/basic/main.go deleted file mode 100644 index 7441afc..0000000 --- a/examples/basic/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - - "github.com/crydensync/cryden" -) - -func main() { - // Create engine - engine := cryden.New() - ctx := context.Background() - - // Sign up - user, err := engine.SignUp(ctx, "test@example.com", "Password123") - if err != nil { - log.Fatal(err) - } - fmt.Printf("User created: %s\n", user.ID) - - // Login - tokens, _, err := engine.Login(ctx, "test@example.com", "Password123") - if err != nil { - log.Fatal(err) - } - fmt.Printf("Access token: %s\n", tokens.AccessToken) -} diff --git a/examples/complete/main.go b/examples/complete/main.go deleted file mode 100644 index 7651ba9..0000000 --- a/examples/complete/main.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "time" - - "github.com/crydensync/cryden" -) - -func main() { - // ==================== SETUP ==================== - ctx := context.Background() - - fmt.Println("πŸš€ Initializing Cryden Auth Engine...") - engine := cryden.New() - - // Optional: Set JWT secret (in production, use environment variable) - cryden.WithJWTSecret(engine, "your-super-secret-key-change-this") - - // ==================== SIGN UP ==================== - fmt.Println("\nπŸ“ Testing SignUp...") - user, err := cryden.SignUp(ctx, engine, "john@example.com", "SecurePass123") - if err != nil { - log.Fatalf("❌ SignUp failed: %v", err) - } - fmt.Printf("βœ… User created: ID=%s, Email=%s\n", user.ID, user.Email) - - // ==================== LOGIN ==================== - fmt.Println("\nπŸ”‘ Testing Login...") - tokens, limit, err := cryden.Login(ctx, engine, "john@example.com", "SecurePass123") - if err != nil { - log.Fatalf("❌ Login failed: %v", err) - } - fmt.Printf("βœ… Login successful!\n") - fmt.Printf(" Access Token: %.40s...\n", tokens.AccessToken) - fmt.Printf(" Refresh Token: %.40s...\n", tokens.RefreshToken) - fmt.Printf(" Rate Limit Remaining: %d\n", limit.Remaining) - - // ==================== VERIFY TOKEN ==================== - fmt.Println("\nπŸ” Testing Token Verification...") - userID, err := cryden.VerifyToken(engine, tokens.AccessToken) - if err != nil { - log.Fatalf("❌ Token verification failed: %v", err) - } - fmt.Printf("βœ… Token verified! User ID: %s\n", userID) - - // ==================== LIST SESSIONS ==================== - fmt.Println("\nπŸ“‹ Listing Active Sessions...") - sessions, err := cryden.ListSessions(ctx, engine, user.ID) - if err != nil { - log.Fatalf("❌ Failed to list sessions: %v", err) - } - fmt.Printf("βœ… Found %d active session(s):\n", len(sessions)) - for i, s := range sessions { - fmt.Printf(" Session %d: %s (expires: %s)\n", i+1, s.ID, s.ExpiresAt.Format(time.RFC3339)) - } - - // ==================== REFRESH TOKEN ==================== - fmt.Println("\nπŸ”„ Testing Token Refresh...") - newTokens, err := cryden.RefreshToken(ctx, engine, tokens.RefreshToken) - if err != nil { - log.Fatalf("❌ Token refresh failed: %v", err) - } - fmt.Printf("βœ… Token refreshed!\n") - fmt.Printf(" New Access Token: %.40s...\n", newTokens.AccessToken) - fmt.Printf(" New Refresh Token: %.40s...\n", newTokens.RefreshToken) - - // ==================== CHANGE PASSWORD ==================== - fmt.Println("\nπŸ” Testing Change Password...") - err = cryden.ChangePassword(ctx, engine, user.ID, "SecurePass123", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Change password failed: %v", err) - } - fmt.Printf("βœ… Password changed successfully!\n") - - // Test login with new password - fmt.Println(" Testing login with new password...") - _, _, err = cryden.Login(ctx, engine, "john@example.com", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Login with new password failed: %v", err) - } - fmt.Printf("βœ… Login with new password successful!\n") - - // ==================== CHANGE EMAIL ==================== - fmt.Println("\nπŸ“§ Testing Change Email...") - err = cryden.ChangeEmail(ctx, engine, user.ID, "john.new@example.com") - if err != nil { - log.Fatalf("❌ Change email failed: %v", err) - } - fmt.Printf("βœ… Email changed to: john.new@example.com\n") - - // Verify email change - updatedUser, err := cryden.GetUser(ctx, engine, user.ID) - if err != nil { - log.Fatalf("❌ Failed to get user: %v", err) - } - fmt.Printf(" User email is now: %s\n", updatedUser.Email) - - // ==================== LOGOUT ==================== - fmt.Println("\nπŸšͺ Testing Logout...") - - // Login again to get new tokens after password change - newTokens, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Login failed: %v", err) - } - - err = cryden.Logout(ctx, engine, newTokens.RefreshToken) - if err != nil { - log.Fatalf("❌ Logout failed: %v", err) - } - fmt.Printf("βœ… Logout successful!\n") - - // Try to refresh with logged out token - should fail - _, err = cryden.RefreshToken(ctx, engine, newTokens.RefreshToken) - if err != nil { - fmt.Printf("βœ… Refresh with logged out token correctly failed: %v\n", err) - } - - // ==================== LOGIN AGAIN FOR LOGOUT ALL TEST ==================== - fmt.Println("\nπŸ”„ Logging in again for Logout All test...") - tokens, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Login failed: %v", err) - } - - // Create another session by logging in again - tokens2, _, err := cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Second login failed: %v", err) - } - fmt.Printf("βœ… Created 2 active sessions\n") - - // ==================== LOGOUT ALL ==================== - fmt.Println("\nπŸšͺ Testing Logout All Devices...") - err = cryden.LogoutAll(ctx, engine, user.ID) - if err != nil { - log.Fatalf("❌ LogoutAll failed: %v", err) - } - fmt.Printf("βœ… Logged out from all devices!\n") - - // Try to use old tokens - should fail - _, err = cryden.RefreshToken(ctx, engine, tokens.RefreshToken) - if err != nil { - fmt.Printf("βœ… First session correctly invalidated: %v\n", err) - } - _, err = cryden.RefreshToken(ctx, engine, tokens2.RefreshToken) - if err != nil { - fmt.Printf("βœ… Second session correctly invalidated: %v\n", err) - } - - // ==================== DELETE ACCOUNT ==================== - fmt.Println("\nπŸ—‘οΈ Testing Delete Account...") - - // Rate limiter blocks after 5 attempts per minute - // Wait for it to reset before final login - fmt.Println("⏱️ Rate limit active - waiting 61 seconds...") - time.Sleep(61 * time.Second) - - // Login one last time - _, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456") - if err != nil { - log.Fatalf("❌ Final login failed: %v", err) - } - - err = cryden.DeleteAccount(ctx, engine, user.ID) - if err != nil { - log.Fatalf("❌ Delete account failed: %v", err) - } - fmt.Printf("βœ… Account deleted successfully!\n") - - // Try to login with deleted account - should fail - _, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456") - if err != nil { - fmt.Printf("βœ… Login with deleted account correctly failed: %v\n", err) - } - - fmt.Println("\nπŸŽ‰ All Cryden features tested successfully!") -} diff --git a/go.mod b/go.mod deleted file mode 100644 index d7a5598..0000000 --- a/go.mod +++ /dev/null @@ -1,23 +0,0 @@ -module github.com/crydensync/cryden - -go 1.25.4 - -require ( - github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/lib/pq v1.11.2 - github.com/mattn/go-sqlite3 v1.14.34 - go.mongodb.org/mongo-driver v1.17.9 - golang.org/x/crypto v0.48.0 -) - -require ( - github.com/golang/snappy v0.0.4 // indirect - github.com/klauspost/compress v1.16.7 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.34.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 11a549a..0000000 --- a/go.sum +++ /dev/null @@ -1,56 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= -github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= -go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/core/audit.go b/internal/core/audit.go deleted file mode 100644 index 8b854c9..0000000 --- a/internal/core/audit.go +++ /dev/null @@ -1,174 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// AuditAction represents what happened -type AuditAction string - -const ( - ActionSignUp AuditAction = "SIGN_UP" - ActionSignInSuccess AuditAction = "SIGN_IN_SUCCESS" - ActionSignInFailed AuditAction = "SIGN_IN_FAILED" - ActionSignOut AuditAction = "SIGN_OUT" - ActionSignOutAll AuditAction = "SIGN_OUT_ALL" - ActionPasswordChange AuditAction = "PASSWORD_CHANGE" - ActionEmailChange AuditAction = "EMAIL_CHANGE" - ActionAccountDelete AuditAction = "ACCOUNT_DELETE" - ActionTokenRefresh AuditAction = "TOKEN_REFRESH" - ActionRateLimited AuditAction = "RATE_LIMITED" -) - -// AuditEntry represents a single audit log entry -type AuditEntry struct { - Timestamp time.Time `json:"timestamp"` - UserID string `json:"user_id,omitempty"` - Action AuditAction `json:"action"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - IPAddress string `json:"ip_address,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -// AuditLogger defines how to log events -type AuditLogger interface { - Log(ctx context.Context, entry AuditEntry) error - Close() error -} - -// ConsoleAuditLogger prints to the console -type ConsoleAuditLogger struct{} - -// NewConsoleAuditLogger creates a new console audit logger -func NewConsoleAuditLogger() *ConsoleAuditLogger { - return &ConsoleAuditLogger{} -} - -// Log prints the audit entry to console -func (l *ConsoleAuditLogger) Log(ctx context.Context, entry AuditEntry) error { - timestamp := entry.Timestamp.Format("2006-01-02 15:04:05") - fmt.Printf("[AUDIT] %s | User: %s | Action: %s | Status: %s\n", - timestamp, entry.UserID, entry.Action, entry.Status) - - if entry.Error != "" { - fmt.Printf(" Error: %s\n", entry.Error) - } - if entry.IPAddress != "" { - fmt.Printf(" IP: %s\n", entry.IPAddress) - } - if len(entry.Metadata) > 0 { - fmt.Printf(" Metadata: %v\n", entry.Metadata) - } - - return nil -} - -// Close closes the logger (no-op for console) -func (l *ConsoleAuditLogger) Close() error { - return nil -} - -// NoopAuditLogger does nothing (for testing) -type NoopAuditLogger struct{} - -// NewNoopAuditLogger creates a new no-op audit logger -func NewNoopAuditLogger() *NoopAuditLogger { - return &NoopAuditLogger{} -} - -// Log does nothing -func (l *NoopAuditLogger) Log(ctx context.Context, entry AuditEntry) error { - return nil -} - -// Close does nothing -func (l *NoopAuditLogger) Close() error { - return nil -} - -// FileAuditLogger writes audit logs to a file in JSON format -type FileAuditLogger struct { - mu sync.Mutex - file *os.File - encoder *json.Encoder - filePath string -} - -// NewFileAuditLogger creates a new file audit logger -func NewFileAuditLogger(filePath string) (*FileAuditLogger, error) { - // Create directory if it doesn't exist - dir := filepath.Dir(filePath) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("failed to create log directory: %w", err) - } - - // Open file for appending - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - - return &FileAuditLogger{ - file: file, - encoder: json.NewEncoder(file), - filePath: filePath, - }, nil -} - -// Log writes an audit entry to the file -func (l *FileAuditLogger) Log(ctx context.Context, entry AuditEntry) error { - l.mu.Lock() - defer l.mu.Unlock() - - // Add timestamp if not set - if entry.Timestamp.IsZero() { - entry.Timestamp = time.Now() - } - - // Write JSON line - if err := l.encoder.Encode(entry); err != nil { - return fmt.Errorf("failed to write audit log: %w", err) - } - - return nil -} - -// Close closes the log file -func (l *FileAuditLogger) Close() error { - l.mu.Lock() - defer l.mu.Unlock() - - if l.file != nil { - return l.file.Close() - } - return nil -} - -// Rotate closes and reopens the log file (for log rotation) -func (l *FileAuditLogger) Rotate() error { - l.mu.Lock() - defer l.mu.Unlock() - - // Close current file - if err := l.file.Close(); err != nil { - return err - } - - // Reopen file - file, err := os.OpenFile(l.filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - - l.file = file - l.encoder = json.NewEncoder(file) - return nil -} diff --git a/internal/core/auth.go b/internal/core/auth.go deleted file mode 100644 index 6b6b1db..0000000 --- a/internal/core/auth.go +++ /dev/null @@ -1,281 +0,0 @@ -package core - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -// SignUp creates a new user account -func (e *Engine) SignUp(ctx context.Context, email, password string) (*User, error) { - // Validate email - if err := ValidateEmail(email); err != nil { - return nil, err - } - - // Validate password - if err := ValidatePassword(password, e.config.PasswordPolicy); err != nil { - return nil, err - } - - // Check if user already exists - existing, err := e.users.GetByEmail(ctx, email) - if err != nil && err != ErrUserNotFound { - return nil, err - } - if existing != nil { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - Action: ActionSignUp, - Status: "FAILED", - Error: "email already exist", - Metadata: map[string]interface{}{"email": email}, - }) - return nil, ErrUserExists - } - - // Hash password - hash, err := e.hasher.Hash(password) - if err != nil { - return nil, err - } - - // Create user - user, err := e.users.Create(ctx, email, hash) - if err != nil { - return nil, err - } - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: user.ID, - Action: ActionSignUp, - Status: "SUCCESS", - IPAddress: getClientIP(ctx), - }) - - return user, nil -} - -// Login authenticates a user and returns tokens -func (e *Engine) Login(ctx context.Context, email, password string, deviceInfo *DeviceInfo, ipAddress string) (*TokenPair, *LimitResult, error) { - key := "login:" + getClientIP(ctx) - - // Check rate limit - result, err := e.rateLimiter.Allow(ctx, key) - if err != nil { - return nil, &result, err - } - - if !result.Allowed { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - Action: ActionRateLimited, - Status: "BLOCKED", - IPAddress: getClientIP(ctx), - Metadata: map[string]interface{}{ - "remaining": result.Remaining, - "reset": result.Reset, - }, - }) - return nil, &result, ErrTooManyAttempts - } - - // Find user - user, err := e.users.GetByEmail(ctx, email) - if err != nil { - if err == ErrUserNotFound { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - Action: ActionSignInFailed, - Status: "FAILED", - Error: "user not found", - IPAddress: getClientIP(ctx), - }) - return nil, &result, ErrInvalidCredentials - } - return nil, &result, err - } - - // Check password - if err := e.hasher.Compare(password, user.PasswordHash); err != nil { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: user.ID, - Action: ActionSignInFailed, - Status: "FAILED", - Error: "wrong password", - IPAddress: getClientIP(ctx), - }) - return nil, &result, ErrInvalidCredentials - } - - // Generate tokens - tokens, err := e.generateTokens(ctx, user.ID, deviceInfo, ipAddress) - if err != nil { - return nil, &result, err - } - - // Reset rate limit on success - //e.rateLimiter.Reset(ctx, key) - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: user.ID, - Action: ActionSignInSuccess, - Status: "SUCCESS", - IPAddress: getClientIP(ctx), - }) - - return tokens, &result, nil -} - -// RefreshToken issues new tokens and rotates the refresh token -func (e *Engine) RefreshToken(ctx context.Context, plainToken string) (*TokenPair, error) { - // Generate lookup hash from plain token - sha := sha256.Sum256([]byte(plainToken)) - lookupHash := hex.EncodeToString(sha[:]) - - // Find session by lookup hash - session, err := e.sessions.GetByRefreshToken(ctx, lookupHash) - if err != nil { - return nil, ErrInvalidToken - } - - // Verify the token matches the stored hash - if err := e.hasher.Compare(plainToken, session.RefreshToken); err != nil { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: session.UserID, - Action: "TOKEN_TAMPERING", - Status: "BLOCKED", - }) - e.sessions.Revoke(ctx, session.ID) - return nil, ErrInvalidToken - } - - // Check expiration - if time.Now().After(session.ExpiresAt) { - e.sessions.Revoke(ctx, session.ID) - return nil, ErrInvalidToken - } - - // Generate new tokens (this creates a new session) - newTokens, err := e.generateTokens(ctx, session.UserID) - if err != nil { - return nil, err - } - - // Revoke old session - if err := e.sessions.Revoke(ctx, session.ID); err != nil { - // Log but continue - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: session.UserID, - Action: "OLD_SESSION_REVOKE_FAILED", - Status: "WARNING", - Error: err.Error(), - }) - } - - // Audit - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: session.UserID, - Action: ActionTokenRefresh, - Status: "SUCCESS", - }) - - return newTokens, nil -} - -// generateTokens creates JWT access token and refresh token -func (e *Engine) generateTokens(ctx context.Context, userID string, deviceInfo *DeviceInfo, ipAddress string) (*TokenPair, error) { - // Generate JWT access token - now := time.Now() - claims := Claims{ - UserID: userID, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(e.config.AccessTokenTTL)), - IssuedAt: jwt.NewNumericDate(now), - NotBefore: jwt.NewNumericDate(now), - Issuer: e.config.Issuer, - Subject: userID, - ID: generateSecureID("tok"), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - accessToken, err := token.SignedString([]byte(e.config.JWTSecret)) - if err != nil { - return nil, fmt.Errorf("failed to sign access token: %w", err) - } - - // Generate refresh token - tokenBytes := make([]byte, 32) - if _, err := rand.Read(tokenBytes); err != nil { - return nil, fmt.Errorf("failed to generate refresh token: %w", err) - } - plainToken := base64.RawURLEncoding.EncodeToString(tokenBytes) - - // Generate SHA256 lookup hash - sha := sha256.Sum256([]byte(plainToken)) - lookupHash := hex.EncodeToString(sha[:]) - - // Generate bcrypt storage hash - storageHash, err := e.hasher.Hash(plainToken) - if err != nil { - return nil, fmt.Errorf("failed to hash refresh token: %w", err) - } - - // Create session - session, err := e.sessions.Create(ctx, userID, storageHash, lookupHash, deviceInfo, ipAddress) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - - // Verify the session was created with the lookup hash - if session.LookupHash != lookupHash { - return nil, fmt.Errorf("session lookup hash mismatch") - } - - return &TokenPair{ - AccessToken: accessToken, - RefreshToken: plainToken, - TokenType: "Bearer", - ExpiresIn: int64(e.config.AccessTokenTTL.Seconds()), - }, nil -} - -// Authenticate extracts user ID from token -func (e *Engine) Authenticate(tokenString string) (string, error) { - claims, err := e.VerifyToken(tokenString) - if err != nil { - return "", err - } - return claims.UserID, nil -} - -// VerifyToken validates a JWT access token -func (e *Engine) VerifyToken(tokenString string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(e.config.JWTSecret), nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to parse token: %w", err) - } - - if claims, ok := token.Claims.(*Claims); ok && token.Valid { - return claims, nil - } - return nil, ErrInvalidToken -} diff --git a/internal/core/device.go b/internal/core/device.go deleted file mode 100644 index 2563b0d..0000000 --- a/internal/core/device.go +++ /dev/null @@ -1,92 +0,0 @@ -package core - -import ( - "strings" -) - -// DeviceInfo contains parsed device information -type DeviceInfo struct { - DeviceName string - DeviceType string - Browser string - OS string -} - -// ParseUserAgent parses a User-Agent string and returns device info -func ParseUserAgent(userAgent string) *DeviceInfo { - if userAgent == "" { - return &DeviceInfo{ - DeviceName: "Unknown", - DeviceType: "unknown", - Browser: "Unknown", - OS: "Unknown", - } - } - - ua := strings.ToLower(userAgent) - info := &DeviceInfo{ - DeviceName: "Unknown", - DeviceType: "desktop", - Browser: "Unknown", - OS: "Unknown", - } - - // Detect Device Type - switch { - case strings.Contains(ua, "mobile"): - info.DeviceType = "mobile" - case strings.Contains(ua, "tablet") || strings.Contains(ua, "ipad"): - info.DeviceType = "tablet" - case strings.Contains(ua, "bot") || strings.Contains(ua, "crawler"): - info.DeviceType = "bot" - } - - // Detect OS - switch { - case strings.Contains(ua, "windows"): - info.OS = "Windows" - if strings.Contains(ua, "windows nt 10.0") { - info.OS = "Windows 10" - } else if strings.Contains(ua, "windows nt 6.1") { - info.OS = "Windows 7" - } - case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh"): - info.OS = "macOS" - case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"): - info.OS = "iOS" - case strings.Contains(ua, "android"): - info.OS = "Android" - case strings.Contains(ua, "linux"): - info.OS = "Linux" - } - - // Detect Browser - switch { - case strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg"): - info.Browser = "Chrome" - case strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome"): - info.Browser = "Safari" - case strings.Contains(ua, "firefox"): - info.Browser = "Firefox" - case strings.Contains(ua, "edg"): - info.Browser = "Edge" - case strings.Contains(ua, "opera") || strings.Contains(ua, "opr"): - info.Browser = "Opera" - } - - // Build device name - deviceParts := []string{} - if info.Browser != "Unknown" { - deviceParts = append(deviceParts, info.Browser) - } - if info.OS != "Unknown" { - deviceParts = append(deviceParts, info.OS) - } - if len(deviceParts) > 0 { - info.DeviceName = strings.Join(deviceParts, " on ") - } else { - info.DeviceName = "Unknown Device" - } - - return info -} diff --git a/internal/core/engine.go b/internal/core/engine.go deleted file mode 100644 index ffee30b..0000000 --- a/internal/core/engine.go +++ /dev/null @@ -1,93 +0,0 @@ -package core - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "time" -) - -// Engine is the main authentication engine -type Engine struct { - users UserStore - sessions SessionStore - hasher Hasher - rateLimiter RateLimiter - auditLogger AuditLogger - config Config -} - -// Config holds engine configuration -type Config struct { - PasswordPolicy PasswordPolicy - JWTSecret string - AccessTokenTTL time.Duration - RefreshTokenTTL time.Duration - Issuer string - TokenExpiry time.Duration -} - -// DefaultConfig returns sensible defaults -func DefaultConfig() Config { - return Config{ - PasswordPolicy: DefaultPasswordPolicy(), - JWTSecret: "change-this-in-production", - AccessTokenTTL: 15 * time.Minute, - RefreshTokenTTL: 7 * 24 * time.Hour, - Issuer: "cryden", - } -} - -// New creates a new authentication engine -func New(users UserStore, sessions SessionStore) *Engine { - return &Engine{ - users: users, - sessions: sessions, - hasher: NewBcryptHasher(10), - rateLimiter: NewMemoryRateLimiter(5, time.Minute), - auditLogger: NewConsoleAuditLogger(), - config: DefaultConfig(), - } -} - -// Configuration setters -func (e *Engine) WithJWTSecret(secret string) *Engine { - e.config.JWTSecret = secret - return e -} - -func (e *Engine) WithHasher(hasher Hasher) *Engine { - e.hasher = hasher - return e -} - -func (e *Engine) WithRateLimiter(limiter RateLimiter) *Engine { - e.rateLimiter = limiter - return e -} - -func (e *Engine) WithAuditLogger(logger AuditLogger) *Engine { - e.auditLogger = logger - return e -} - -// Getter methods for testing -func (e *Engine) GetUserStore() UserStore { - return e.users -} - -func (e *Engine) GetSessionStore() SessionStore { - return e.sessions -} - -// GetHasher returns the hasher (for testing) -func (e *Engine) GetHasher() Hasher { - return e.hasher -} - -// GetSessionByRefreshToken returns a session using the plain refresh token -func (e *Engine) GetSessionByRefreshToken(ctx context.Context, plainToken string) (*Session, error) { - sha := sha256.Sum256([]byte(plainToken)) - lookupHash := hex.EncodeToString(sha[:]) - return e.sessions.GetByRefreshToken(ctx, lookupHash) -} diff --git a/internal/core/errors.go b/internal/core/errors.go deleted file mode 100644 index 6f12a90..0000000 --- a/internal/core/errors.go +++ /dev/null @@ -1,44 +0,0 @@ -package core - -import "errors" - -var ( - // user errors - ErrUserNotFound = errors.New("user not found") - ErrUserExists = errors.New("user alredy exists") - ErrInvalidEmail = errors.New("incorrect email format") - - // password errors - ErrPasswordTooShort = errors.New("password must be at least 8 characters") - ErrPasswordTooLong = errors.New("password execceds maximum lenght") - ErrPasswordNoUpper = errors.New("password must contain an uppercase letter") - ErrPasswordNoLower = errors.New("password must contain a lowercase letter") - ErrPasswordNoNumber = errors.New("password must contain a number") - - // Rate limit errors - ErrTooManyAttempts = errors.New("too many attempts, please try again later") - - // auth errors - ErrInvalidCredentials = errors.New("invalid email or password") - ErrInvalidToken = errors.New("invalid or expired token") - // token errors - ErrInvalidSession = errors.New("invalid or expired token") - ErrSessionNotFound = errors.New("session not found") - // Audit errors - ErrAuditLogFailed = errors.New("Failed to write audit logs") -) - -// validation error provides field level error details -type ValidationError struct { - Field string - Message string - Err error -} - -func (e *ValidationError) Error() string { - return e.Field + ": " + e.Message -} - -func (e *ValidationError) Unwrap() error { - return e.Err -} diff --git a/internal/core/hasher.go b/internal/core/hasher.go deleted file mode 100644 index 3d893a9..0000000 --- a/internal/core/hasher.go +++ /dev/null @@ -1,48 +0,0 @@ -package core - -import "golang.org/x/crypto/bcrypt" - -// Hasher defines password opperation -type Hasher interface { - Hash(password string) (string, error) - Compare(password, hash string) error -} - -// BcryptHasher implement Hasher using bcrypt -type BcryptHasher struct { - Cost int -} - -func NewBcryptHasher(cost int) *BcryptHasher { - if cost < 4 || cost > 31 { - cost = 10 - } - return &BcryptHasher{Cost: cost} -} - -func (h *BcryptHasher) Hash(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), h.Cost) - return string(hash), err -} - -func (h *BcryptHasher) Compare(password, hash string) error { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - if err != nil { - return ErrInvalidCredentials - } - return nil -} - -// MockHasher for fast tests -type MockHasher struct{} - -func (h *MockHasher) Hash(password string) (string, error) { - return password, nil -} - -func (h *MockHasher) Compare(password, hash string) error { - if password == hash { - return nil - } - return ErrInvalidCredentials -} diff --git a/internal/core/helpers.go b/internal/core/helpers.go deleted file mode 100644 index fcd682e..0000000 --- a/internal/core/helpers.go +++ /dev/null @@ -1,40 +0,0 @@ -package core - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - "time" -) - -// getClientIP returns client IP from context or default -// In production, users will set this via middleware -// For now, this works for all tests and examples -func getClientIP(ctx context.Context) string { - // Try to get from context (if set by middleware) - if ip := ctx.Value("client_ip"); ip != nil { - if ipStr, ok := ip.(string); ok && ipStr != "" { - return ipStr - } - } - - // Default for testing/development - return "127.0.0.1" -} - -// generateSecureID creates a unique ID with randomness -func generateSecureID(prefix string) string { - b := make([]byte, 8) - if _, err := rand.Read(b); err != nil { - // Fallback if rand fails (very rare) - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) - } - random := base64.RawURLEncoding.EncodeToString(b) - return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixNano(), random) -} - -// generateID kept for backward compatibility -func generateID() string { - return generateSecureID("gen") -} diff --git a/internal/core/interfaces.go b/internal/core/interfaces.go deleted file mode 100644 index 895fd0e..0000000 --- a/internal/core/interfaces.go +++ /dev/null @@ -1,37 +0,0 @@ -package core - -import "context" - -// UserStore defines how we store and retrieve users -type UserStore interface { - Create(ctx context.Context, email, passwordHash string) (*User, error) - GetByEmail(ctx context.Context, email string) (*User, error) - GetByID(ctx context.Context, id string) (*User, error) - UpdateEmail(ctx context.Context, id, newEmail string) error - UpdatePassword(ctx context.Context, id, newPasswordHash string) error - Delete(ctx context.Context, id string) error - Close() error -} - -/* -// SessionStore defines how we store and retrieve sessions -type SessionStore interface { - Create(ctx context.Context, userID, refreshTokenHash, lookupHash string) (*Session, error) - GetByRefreshToken(ctx context.Context, lookupHash string) (*Session, error) - Revoke(ctx context.Context, sessionID string) error - RevokeAllForUser(ctx context.Context, userID string) error - ListForUser(ctx context.Context, userID string) ([]Session, error) - Close() error -} -*/ - -// SessionStore defines how we store and retrieve sessions -type SessionStore interface { - Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *DeviceInfo, ipAddress string) (*Session, error) - UpdateLastSeen(ctx context.Context, sessionID string) error - GetByRefreshToken(ctx context.Context, lookupHash string) (*Session, error) - Revoke(ctx context.Context, sessionID string) error - RevokeAllForUser(ctx context.Context, userID string) error - ListForUser(ctx context.Context, userID string) ([]Session, error) - Close() error -} diff --git a/internal/core/models.go b/internal/core/models.go deleted file mode 100644 index b7428cc..0000000 --- a/internal/core/models.go +++ /dev/null @@ -1,58 +0,0 @@ -package core - -import ( - "time" - "github.com/golang-jwt/jwt/v5" -) - -// User represents a user in the system -type User struct { - ID string - Email string - PasswordHash string - CreatedAt time.Time - UpdatedAt time.Time -} - -// Session represents a user session -type Session struct { - ID string `json:"id"` - UserID string `json:"user_id"` - RefreshToken string `json:"refresh_token"` - LookupHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - LastSeenAt time.Time `json:"last_seen_at"` - IPAddress string `json:"ip_address,omitempty"` - DeviceName string `json:"device_name,omitempty"` - DeviceType string `json:"device_type,omitempty"` - Browser string `json:"browser,omitempty"` - OS string `json:"os,omitempty"` - Location string `json:"location,omitempty"` -} - -/* -// Session represents a user session with hashed refresh token -type Session struct { - ID string - UserID string - RefreshToken string // bcrypt hash of refresh token (stored) - LookupHash string // SHA256 hash for fast DB lookup (indexed) - CreatedAt time.Time - ExpiresAt time.Time -} -*/ - -// TokenPair contains access and refresh tokens -type TokenPair struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` // Plain text token sent to client - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` -} - -// Claims represents JWT claims -type Claims struct { - UserID string `json:"user_id"` - jwt.RegisteredClaims -} diff --git a/internal/core/rate_limiter.go b/internal/core/rate_limiter.go deleted file mode 100644 index 3800715..0000000 --- a/internal/core/rate_limiter.go +++ /dev/null @@ -1,117 +0,0 @@ -package core - -import ( - "context" - "sync" - "time" -) - -// LimitResult contains rate limit info for response headers -type LimitResult struct { - Allowed bool - Limit int - Remaining int - Reset time.Duration -} - -// RateLimiter defines how rate limiting works -type RateLimiter interface { - // Allow checks if request is permitted - Allow(ctx context.Context, key string) (LimitResult, error) - - // Reset clears limit for a key - Reset(ctx context.Context, key string) error -} - -// MemoryRateLimiter implements RateLimiter in memory -type MemoryRateLimiter struct { - mu sync.RWMutex - attempts map[string][]time.Time - limit int - window time.Duration -} - -// NewMemoryRateLimiter creates a new memory rate limiter -func NewMemoryRateLimiter(limit int, window time.Duration) *MemoryRateLimiter { - return &MemoryRateLimiter{ - attempts: make(map[string][]time.Time), - limit: limit, - window: window, - } -} - -// Allow checks if a request is within rate limit -func (r *MemoryRateLimiter) Allow(ctx context.Context, key string) (LimitResult, error) { - r.mu.Lock() - defer r.mu.Unlock() - - now := time.Now() - cutoff := now.Add(-r.window) - - // Clean old attempts - attempts := r.attempts[key] - valid := make([]time.Time, 0) - for _, t := range attempts { - if t.After(cutoff) { - valid = append(valid, t) - } - } - - // Check if under limit (strict: < limit means allowed) - if len(valid) < r.limit { - // Add current attempt - valid = append(valid, now) - r.attempts[key] = valid - - remaining := r.limit - len(valid) - if remaining < 0 { - remaining = 0 - } - - return LimitResult{ - Allowed: true, - Limit: r.limit, - Remaining: remaining, - Reset: 0, - }, nil - } - - // Rate limited - oldest := valid[0] - resetTime := oldest.Add(r.window) - resetDuration := time.Until(resetTime) - if resetDuration < 0 { - resetDuration = 0 - } - - return LimitResult{ - Allowed: false, - Limit: r.limit, - Remaining: 0, - Reset: resetDuration, - }, nil -} - -// Reset clears rate limit for a key -func (r *MemoryRateLimiter) Reset(ctx context.Context, key string) error { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.attempts, key) - return nil -} - -// NoopRateLimiter for testing - allows everything -type NoopRateLimiter struct{} - -func (r *NoopRateLimiter) Allow(ctx context.Context, key string) (LimitResult, error) { - return LimitResult{ - Allowed: true, - Limit: 0, - Remaining: 0, - Reset: 0, - }, nil -} - -func (r *NoopRateLimiter) Reset(ctx context.Context, key string) error { - return nil -} diff --git a/internal/core/session.go b/internal/core/session.go deleted file mode 100644 index db791ba..0000000 --- a/internal/core/session.go +++ /dev/null @@ -1,62 +0,0 @@ -package core - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "time" - -) - -// ListSessions returns all active sessions for a user -func (e *Engine) ListSessions(ctx context.Context, userID string) ([]Session, error) { - return e.sessions.ListForUser(ctx, userID) -} - -// RevokeSession manually revokes a specific session -func (e *Engine) RevokeSession(ctx context.Context, sessionID string) error { - return e.sessions.Revoke(ctx, sessionID) -} - -// Logout revokes the current session -func (e *Engine) Logout(ctx context.Context, refreshToken string) error { - // Generate lookup hash from plain token - sha := sha256.Sum256([]byte(refreshToken)) - lookupHash := hex.EncodeToString(sha[:]) - - session, err := e.sessions.GetByRefreshToken(ctx, lookupHash) - if err != nil { - return ErrInvalidToken - } - - if err := e.sessions.Revoke(ctx, session.ID); err != nil { - return err - } - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: session.UserID, - Action: ActionSignOut, - Status: "SUCCESS", - IPAddress: getClientIP(ctx), - }) - - return nil -} - -// LogoutAll revokes ALL sessions for a user -func (e *Engine) LogoutAll(ctx context.Context, userID string) error { - if err := e.sessions.RevokeAllForUser(ctx, userID); err != nil { - return err - } - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: userID, - Action: ActionSignOutAll, - Status: "SUCCESS", - IPAddress: getClientIP(ctx), - }) - - return nil -} diff --git a/internal/core/user.go b/internal/core/user.go deleted file mode 100644 index 6a2f211..0000000 --- a/internal/core/user.go +++ /dev/null @@ -1,119 +0,0 @@ -package core - -import ( - "context" - "time" -) - -// GetUser retrieves a user by ID -func (e *Engine) GetUser(ctx context.Context, userID string) (*User, error) { - return e.users.GetByID(ctx, userID) -} - -// GetUserByEmail retrieves a user by email -func (e *Engine) GetUserByEmail(ctx context.Context, email string) (*User, error) { - return e.users.GetByEmail(ctx, email) -} - -// ChangePassword updates user's password and logs out all devices -func (e *Engine) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error { - // Get user - user, err := e.users.GetByID(ctx, userID) - if err != nil { - return err - } - - // Verify old password - if err := e.hasher.Compare(oldPassword, user.PasswordHash); err != nil { - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: userID, - Action: ActionPasswordChange, - Status: "FAILED", - Error: "wrong old password", - }) - return ErrInvalidCredentials - } - - // Validate new password - if err := ValidatePassword(newPassword, e.config.PasswordPolicy); err != nil { - return err - } - - // Hash new password - newHash, err := e.hasher.Hash(newPassword) - if err != nil { - return err - } - - // Update in database - if err := e.users.UpdatePassword(ctx, userID, newHash); err != nil { - return err - } - - // Logout all devices (security best practice) - e.sessions.RevokeAllForUser(ctx, userID) - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: userID, - Action: ActionPasswordChange, - Status: "SUCCESS", - }) - - return nil -} - -// ChangeEmail updates user's email -func (e *Engine) ChangeEmail(ctx context.Context, userID, newEmail string) error { - // Validate email - if err := ValidateEmail(newEmail); err != nil { - return err - } - - // Check if email already exists - existing, err := e.users.GetByEmail(ctx, newEmail) - if err != nil && err != ErrUserNotFound { - return err - } - if existing != nil { - return ErrUserExists - } - - // Update email - if err := e.users.UpdateEmail(ctx, userID, newEmail); err != nil { - return err - } - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: userID, - Action: ActionEmailChange, - Status: "SUCCESS", - Metadata: map[string]interface{}{ - "new_email": newEmail, - }, - }) - - return nil -} - -// DeleteAccount removes user and all sessions -func (e *Engine) DeleteAccount(ctx context.Context, userID string) error { - // Delete all sessions first - e.sessions.RevokeAllForUser(ctx, userID) - - // Delete user - if err := e.users.Delete(ctx, userID); err != nil { - return err - } - - e.auditLogger.Log(ctx, AuditEntry{ - Timestamp: time.Now(), - UserID: userID, - Action: ActionAccountDelete, - Status: "SUCCESS", - }) - - return nil -} diff --git a/internal/core/validations.go b/internal/core/validations.go deleted file mode 100644 index e8cdd6f..0000000 --- a/internal/core/validations.go +++ /dev/null @@ -1,154 +0,0 @@ -package core - -import ( - "strings" - "unicode" -) - -// ValidateEmail checks if email is valid -func ValidateEmail(email string) error { - // Trim spaces - email = strings.TrimSpace(email) - // Check if empty - if email == "" { - return &ValidationError{ - Field: "email", - Message: "email cannot be empty", - Err: ErrInvalidEmail, - } - } - // Check lenght (RFC 5321) - if len(email) > 254 { - return &ValidationError{ - Field: "email", - Message: "email too long", - Err: ErrInvalidEmail, - } - } - // Must contail "@" - if !strings.Contains(email, "@") { - return &ValidationError{ - Field: "email", - Message: "email must contain @ symbol", - Err: ErrInvalidEmail, - } - } - // Split local and domain part - parts := strings.Split(email, "@") - if len(parts) != 2 { - return &ValidationError{ - Field: "email", - Message: "email must contain exactly on@ symbol", - } - } - local, domain := parts[0], parts[1] - - // Check local part not empty - if local == "" { - return &ValidationError{ - Field: "email", - Message: "email local part cannot be empty", - Err: ErrInvalidEmail, - } - } - - // Check domain not empty - if domain == "" { - return &ValidationError{ - Field: "email", - Message: "email domain connot be empty", - Err: ErrInvalidEmail, - } - } - - // Domain must contain . dot - if !strings.Contains(domain, ".") { - return &ValidationError{ - Field: "email", - Message: "email must contain a . dot", - Err: ErrInvalidEmail, - } - } - - return nil -} - -// PasswordPolicy defines rules for passwords -type PasswordPolicy struct { - MinLenght int - MaxLenght int - RequireUpper bool - RequireLower bool - RequireNumber bool - RequireSpecial bool -} - -// DefaultPasswordPolicy returns sensible defauls -func DefaultPasswordPolicy() PasswordPolicy { - return PasswordPolicy{ - MinLenght: 8, - MaxLenght: 72, //bcrypt limit - RequireUpper: true, - RequireLower: true, - RequireNumber: true, - RequireSpecial: false, // for now test MVP - } -} - -func ValidatePassword(password string, policy PasswordPolicy) error { - // Check length - if len(password) < policy.MinLenght { - return &ValidationError{ - Field: "password", - Message: "password too short", - Err: ErrPasswordTooShort, - } - } - - if len(password) > policy.MaxLenght { - return &ValidationError{ - Field: "password", - Message: "password too long", - Err: ErrPasswordTooLong, - } - } - - // Check character requirements - var hasUpper, hasLower, hasNumber bool - for _, char := range password { - switch { - case unicode.IsUpper(char): - hasUpper = true - case unicode.IsLower(char): - hasLower = true - case unicode.IsDigit(char): - hasNumber = true - } - } - - if policy.RequireUpper && !hasUpper { - return &ValidationError{ - Field: "password", - Message: "password must contain uppercase letter", - Err: ErrPasswordNoUpper, - } - } - - if policy.RequireLower && !hasLower { - return &ValidationError{ - Field: "password", - Message: "password must contain lowercase letter", - Err: ErrPasswordNoLower, - } - } - - if policy.RequireNumber && !hasNumber { - return &ValidationError{ - Field: "password", - Message: "password must contain number", - Err: ErrPasswordNoNumber, - } - } - - return nil -} diff --git a/internal/stores/memory/session_store.go b/internal/stores/memory/session_store.go deleted file mode 100644 index 47a8d89..0000000 --- a/internal/stores/memory/session_store.go +++ /dev/null @@ -1,180 +0,0 @@ -package memory - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/crydensync/cryden/internal/core" -) - -// SessionStore implements core.SessionStore with in-memory storage -type SessionStore struct { - mu sync.RWMutex - byID map[string]*core.Session - byUser map[string][]*core.Session - byLookup map[string]string -} - -// NewSessionStore creates a new in-memory session store -func NewSessionStore() *SessionStore { - return &SessionStore{ - byID: make(map[string]*core.Session), - byUser: make(map[string][]*core.Session), - byLookup: make(map[string]string), - } -} - -// Create stores a new session -func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.byLookup[lookupHash]; exists { - return nil, fmt.Errorf("lookup hash collision") - } - - now := time.Now() - session := &core.Session{ - ID: generateID(), - UserID: userID, - RefreshToken: refreshTokenHash, - LookupHash: lookupHash, - CreatedAt: now, - ExpiresAt: now.Add(7 * 24 * time.Hour), - LastSeenAt: now, - IPAddress: ipAddress, - } - - if device != nil { - session.DeviceName = device.DeviceName - session.DeviceType = device.DeviceType - session.Browser = device.Browser - session.OS = device.OS - } - - s.byID[session.ID] = session - s.byLookup[lookupHash] = session.ID - s.byUser[userID] = append(s.byUser[userID], session) - - return session, nil -} - -// UpdateLastSeen updates the last seen time for a session -func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.byID[sessionID] - if !exists { - return core.ErrSessionNotFound - } - - session.LastSeenAt = time.Now() - return nil -} - -// ListForUser returns all sessions for a user -func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - sessions, exists := s.byUser[userID] - if !exists { - return []core.Session{}, nil - } - - now := time.Now() - var active []core.Session - for _, session := range sessions { - if now.Before(session.ExpiresAt) { - activeSession := *session - activeSession.LookupHash = "" - active = append(active, activeSession) - } - } - - return active, nil -} - -// GetByRefreshToken finds session using lookup hash -func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - sessionID, exists := s.byLookup[lookupHash] - if !exists { - return nil, core.ErrSessionNotFound - } - - session, exists := s.byID[sessionID] - if !exists { - delete(s.byLookup, lookupHash) - return nil, core.ErrSessionNotFound - } - - // Return a copy to prevent modification - sessionCopy := *session - return &sessionCopy, nil -} - -// Revoke removes a specific session -func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.byID[sessionID] - if !exists { - return core.ErrSessionNotFound - } - - delete(s.byLookup, session.LookupHash) - - // Remove from ID map - delete(s.byID, sessionID) - - // Remove from user's session list - userSessions := s.byUser[session.UserID] - for i, sess := range userSessions { - if sess.ID == sessionID { - // Remove by swapping with last element - userSessions[i] = userSessions[len(userSessions)-1] - s.byUser[session.UserID] = userSessions[:len(userSessions)-1] - break - } - } - - return nil -} - -// RevokeAllForUser removes all sessions for a user -func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - sessions, exists := s.byUser[userID] - if !exists { - return nil - } - - // Remove each session - for _, session := range sessions { - delete(s.byLookup, session.LookupHash) - delete(s.byID, session.ID) - } - - delete(s.byUser, userID) - - return nil -} - -// Helper function to generate IDs -func generateID() string { - return fmt.Sprintf("sess_%d", time.Now().UnixNano()) -} - -func (s *SessionStore) Close() error { - // Memory store doesn't need cleanup - return nil -} diff --git a/internal/stores/memory/user_store.go b/internal/stores/memory/user_store.go deleted file mode 100644 index 0d3a85e..0000000 --- a/internal/stores/memory/user_store.go +++ /dev/null @@ -1,139 +0,0 @@ -package memory - -import ( - "context" - "sync" - "time" - - "github.com/crydensync/cryden/internal/core" -) - -// UserStore implements core.userStore using in-memory map -// Good for testing and development -type UserStore struct { - mu sync.RWMutex - byEmail map[string]*core.User - byID map[string]*core.User -} - -// NewUserStore creates new in-memory user -func NewUserStore() *UserStore { - return &UserStore{ - byEmail: make(map[string]*core.User), - byID: make(map[string]*core.User), - } -} - -// Create stores a user -func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) { - s.mu.Lock() - defer s.mu.Unlock() - - //check if user already exits - if _, exits := s.byEmail[email]; exits { - return nil, core.ErrUserExists - } - - // create user with generated byID - user := &core.User{ - ID: generateID(), - Email: email, - PasswordHash: passwordHash, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - s.byEmail[email] = user - s.byID[user.ID] = user - - return user, nil -} - -// GetByEmail retrieves a user by email -func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - user, exits := s.byEmail[email] - if !exits { - return nil, core.ErrUserNotFound - } - return user, nil -} - -// GetByID retrieve a user by ID -func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - user, exits := s.byID[id] - if !exits { - return nil, core.ErrUserNotFound - } - return user, nil -} - -// UpdateEmail changes a user's email -func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error { - s.mu.Lock() - defer s.mu.Unlock() - - user, exits := s.byID[id] - if !exits { - return core.ErrUserNotFound - } - - // Check of new email is taken - if _, exits := s.byEmail[newEmail]; exits { - return core.ErrUserExists - } - - // Remove old email - delete(s.byEmail, user.Email) - - // Upadte email - user.Email = newEmail - user.UpdatedAt = time.Now() - - // Add new email mapping - s.byEmail[newEmail] = user - - return nil -} - -// UpdatePassword changes user's password -func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error { - s.mu.Lock() - defer s.mu.Unlock() - - user, exits := s.byID[id] - if !exits { - return core.ErrUserNotFound - } - - user.PasswordHash = newPasswordHash - user.UpdatedAt = time.Now() - - return nil -} - -// Delete removes a user -func (s *UserStore) Delete(ctx context.Context, id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - user, exits := s.byID[id] - if !exits { - return core.ErrUserNotFound - } - - delete(s.byEmail, user.Email) - delete(s.byID, user.ID) - - return nil -} - -func (s *UserStore) Close() error { - // Memory store doesn't need cleanup - return nil -} diff --git a/internal/stores/mongodb/session_store.go b/internal/stores/mongodb/session_store.go deleted file mode 100644 index 9c52b5b..0000000 --- a/internal/stores/mongodb/session_store.go +++ /dev/null @@ -1,236 +0,0 @@ -package mongodb - -import ( - "context" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -type SessionStore struct { - collection *mongo.Collection -} - -type mongoSession struct { - ID string `bson:"_id"` - UserID string `bson:"user_id"` - RefreshToken string `bson:"refresh_token"` - LookupHash string `bson:"lookup_hash"` - CreatedAt time.Time `bson:"created_at"` - ExpiresAt time.Time `bson:"expires_at"` - LastSeenAt time.Time `bson:"last_seen_at"` - IPAddress string `bson:"ip_address,omitempty"` - DeviceName string `bson:"device_name,omitempty"` - DeviceType string `bson:"device_type,omitempty"` - Browser string `bson:"browser,omitempty"` - OS string `bson:"os,omitempty"` -} - -func NewSessionStore(uri, dbName string) (*SessionStore, error) { - client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri)) - if err != nil { - return nil, fmt.Errorf("failed to connect to MongoDB: %w", err) - } - - if err := client.Ping(context.Background(), nil); err != nil { - return nil, fmt.Errorf("failed to ping MongoDB: %w", err) - } - - collection := client.Database(dbName).Collection("sessions") - - // Create indexes - lookupIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "lookup_hash", Value: 1}}, - Options: options.Index().SetUnique(true), - } - - userIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "user_id", Value: 1}}, - } - - ttlIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "expires_at", Value: 1}}, - Options: options.Index().SetExpireAfterSeconds(0), - } - - lastSeenIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "last_seen_at", Value: -1}}, - } - - _, err = collection.Indexes().CreateMany(context.Background(), []mongo.IndexModel{ - lookupIndex, - userIndex, - ttlIndex, - lastSeenIndex, - }) - if err != nil { - return nil, fmt.Errorf("failed to create indexes: %w", err) - } - - return &SessionStore{collection: collection}, nil -} - -// Create stores a new session with device info -func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) { - now := time.Now() - - session := mongoSession{ - ID: fmt.Sprintf("sess_%d", now.UnixNano()), - UserID: userID, - RefreshToken: refreshTokenHash, - LookupHash: lookupHash, - CreatedAt: now, - ExpiresAt: now.Add(7 * 24 * time.Hour), - LastSeenAt: now, - IPAddress: ipAddress, - } - - if device != nil { - session.DeviceName = device.DeviceName - session.DeviceType = device.DeviceType - session.Browser = device.Browser - session.OS = device.OS - } - - _, err := s.collection.InsertOne(ctx, session) - if err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, fmt.Errorf("lookup hash already exists: %w", err) - } - return nil, fmt.Errorf("failed to create session: %w", err) - } - - return &core.Session{ - ID: session.ID, - UserID: session.UserID, - RefreshToken: session.RefreshToken, - LookupHash: session.LookupHash, - CreatedAt: session.CreatedAt, - ExpiresAt: session.ExpiresAt, - LastSeenAt: session.LastSeenAt, - IPAddress: session.IPAddress, - DeviceName: session.DeviceName, - DeviceType: session.DeviceType, - Browser: session.Browser, - OS: session.OS, - }, nil -} - -// GetByRefreshToken finds session using lookup hash -func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) { - var session mongoSession - err := s.collection.FindOne(ctx, bson.M{ - "lookup_hash": lookupHash, - "expires_at": bson.M{"$gt": time.Now()}, - }).Decode(&session) - - if err == mongo.ErrNoDocuments { - return nil, core.ErrSessionNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - - return &core.Session{ - ID: session.ID, - UserID: session.UserID, - RefreshToken: session.RefreshToken, - LookupHash: session.LookupHash, - CreatedAt: session.CreatedAt, - ExpiresAt: session.ExpiresAt, - LastSeenAt: session.LastSeenAt, - IPAddress: session.IPAddress, - DeviceName: session.DeviceName, - DeviceType: session.DeviceType, - Browser: session.Browser, - OS: session.OS, - }, nil -} - -// UpdateLastSeen updates the last seen time for a session -func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error { - result, err := s.collection.UpdateOne( - ctx, - bson.M{"_id": sessionID}, - bson.M{"$set": bson.M{"last_seen_at": time.Now()}}, - ) - if err != nil { - return fmt.Errorf("failed to update last seen: %w", err) - } - - if result.MatchedCount == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// Revoke removes a specific session -func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error { - result, err := s.collection.DeleteOne(ctx, bson.M{"_id": sessionID}) - if err != nil { - return fmt.Errorf("failed to revoke session: %w", err) - } - - if result.DeletedCount == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// RevokeAllForUser removes all sessions for a user -func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error { - _, err := s.collection.DeleteMany(ctx, bson.M{"user_id": userID}) - if err != nil { - return fmt.Errorf("failed to revoke all sessions: %w", err) - } - - return nil -} - -// ListForUser returns all active sessions for a user -func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) { - cursor, err := s.collection.Find(ctx, bson.M{ - "user_id": userID, - "expires_at": bson.M{"$gt": time.Now()}, - }, options.Find().SetSort(bson.M{"last_seen_at": -1})) - - if err != nil { - return nil, fmt.Errorf("failed to list sessions: %w", err) - } - defer cursor.Close(ctx) - - var sessions []core.Session - for cursor.Next(ctx) { - var ms mongoSession - if err := cursor.Decode(&ms); err != nil { - return nil, fmt.Errorf("failed to decode session: %w", err) - } - - sessions = append(sessions, core.Session{ - ID: ms.ID, - UserID: ms.UserID, - RefreshToken: ms.RefreshToken, - LookupHash: "", - CreatedAt: ms.CreatedAt, - ExpiresAt: ms.ExpiresAt, - LastSeenAt: ms.LastSeenAt, - IPAddress: ms.IPAddress, - DeviceName: ms.DeviceName, - DeviceType: ms.DeviceType, - Browser: ms.Browser, - OS: ms.OS, - }) - } - - return sessions, nil -} - -func (s *SessionStore) Close() error { - return nil -} diff --git a/internal/stores/mongodb/user_store.go b/internal/stores/mongodb/user_store.go deleted file mode 100644 index 49f293d..0000000 --- a/internal/stores/mongodb/user_store.go +++ /dev/null @@ -1,175 +0,0 @@ -package mongodb - -import ( - "context" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -type UserStore struct { - collection *mongo.Collection -} - -type mongoUser struct { - ID string `bson:"_id"` - Email string `bson:"email"` - PasswordHash string `bson:"password_hash"` - CreatedAt time.Time `bson:"created_at"` - UpdatedAt time.Time `bson:"updated_at"` -} - -func NewUserStore(uri, dbName string) (*UserStore, error) { - client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri)) - if err != nil { - return nil, fmt.Errorf("failed to connect to MongoDB: %w", err) - } - - if err := client.Ping(context.Background(), nil); err != nil { - return nil, fmt.Errorf("failed to ping MongoDB: %w", err) - } - - collection := client.Database(dbName).Collection("users") - - // Create unique index on email - indexModel := mongo.IndexModel{ - Keys: bson.D{{Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true), - } - - _, err = collection.Indexes().CreateOne(context.Background(), indexModel) - if err != nil { - return nil, fmt.Errorf("failed to create index: %w", err) - } - - return &UserStore{collection: collection}, nil -} - -func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) { - now := time.Now() - user := mongoUser{ - ID: fmt.Sprintf("usr_%d", time.Now().UnixNano()), - Email: email, - PasswordHash: passwordHash, - CreatedAt: now, - UpdatedAt: now, - } - - _, err := s.collection.InsertOne(ctx, user) - if err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, core.ErrUserExists - } - return nil, fmt.Errorf("failed to create user: %w", err) - } - - return &core.User{ - ID: user.ID, - Email: user.Email, - PasswordHash: user.PasswordHash, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - }, nil -} - -func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) { - var user mongoUser - err := s.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user) - - if err == mongo.ErrNoDocuments { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &core.User{ - ID: user.ID, - Email: user.Email, - PasswordHash: user.PasswordHash, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - }, nil -} - -func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) { - var user mongoUser - err := s.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) - - if err == mongo.ErrNoDocuments { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &core.User{ - ID: user.ID, - Email: user.Email, - PasswordHash: user.PasswordHash, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - }, nil -} - -func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error { - result, err := s.collection.UpdateOne( - ctx, - bson.M{"_id": id}, - bson.M{"$set": bson.M{"email": newEmail, "updated_at": time.Now()}}, - ) - - if err != nil { - if mongo.IsDuplicateKeyError(err) { - return core.ErrUserExists - } - return fmt.Errorf("failed to update email: %w", err) - } - - if result.MatchedCount == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error { - result, err := s.collection.UpdateOne( - ctx, - bson.M{"_id": id}, - bson.M{"$set": bson.M{"password_hash": newPasswordHash, "updated_at": time.Now()}}, - ) - - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - if result.MatchedCount == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) Delete(ctx context.Context, id string) error { - result, err := s.collection.DeleteOne(ctx, bson.M{"_id": id}) - - if err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - - if result.DeletedCount == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) Close() error { - // MongoDB client is managed elsewhere - return nil -} diff --git a/internal/stores/postgres/session_store.go b/internal/stores/postgres/session_store.go deleted file mode 100644 index 78a6c17..0000000 --- a/internal/stores/postgres/session_store.go +++ /dev/null @@ -1,190 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - _ "github.com/lib/pq" -) - -type SessionStore struct { - db *sql.DB -} - -func NewSessionStore(db *sql.DB) *SessionStore { - return &SessionStore{db: db} -} - -// Create stores a new session with device info -func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) { - query := ` - INSERT INTO sessions (id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - ` - - id := fmt.Sprintf("sess_%d", time.Now().UnixNano()) - now := time.Now() - expiresAt := now.Add(7 * 24 * time.Hour) - - deviceName := "" - deviceType := "" - browser := "" - os := "" - - if device != nil { - deviceName = device.DeviceName - deviceType = device.DeviceType - browser = device.Browser - os = device.OS - } - - var session core.Session - err := s.db.QueryRowContext(ctx, query, - id, userID, refreshTokenHash, lookupHash, now, expiresAt, now, ipAddress, - deviceName, deviceType, browser, os, - ).Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.LookupHash, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - - return &session, nil -} - -// GetByRefreshToken finds session using lookup hash -func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) { - query := ` - SELECT id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - FROM sessions - WHERE lookup_hash = $1 AND expires_at > $2 - ` - - var session core.Session - err := s.db.QueryRowContext(ctx, query, lookupHash, time.Now()).Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.LookupHash, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrSessionNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - - return &session, nil -} - -// UpdateLastSeen updates the last seen time for a session -func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error { - query := `UPDATE sessions SET last_seen_at = $1 WHERE id = $2` - - result, err := s.db.ExecContext(ctx, query, time.Now(), sessionID) - if err != nil { - return fmt.Errorf("failed to update last seen: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// Revoke removes a specific session -func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error { - query := `DELETE FROM sessions WHERE id = $1` - - result, err := s.db.ExecContext(ctx, query, sessionID) - if err != nil { - return fmt.Errorf("failed to revoke session: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// RevokeAllForUser removes all sessions for a user -func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error { - query := `DELETE FROM sessions WHERE user_id = $1` - - _, err := s.db.ExecContext(ctx, query, userID) - if err != nil { - return fmt.Errorf("failed to revoke all sessions: %w", err) - } - - return nil -} - -// ListForUser returns all active sessions for a user -func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) { - query := ` - SELECT id, user_id, refresh_token, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - FROM sessions - WHERE user_id = $1 AND expires_at > $2 - ORDER BY last_seen_at DESC - ` - - rows, err := s.db.QueryContext(ctx, query, userID, time.Now()) - if err != nil { - return nil, fmt.Errorf("failed to list sessions: %w", err) - } - defer rows.Close() - - var sessions []core.Session - for rows.Next() { - var session core.Session - err := rows.Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan session: %w", err) - } - session.LookupHash = "" - sessions = append(sessions, session) - } - - return sessions, nil -} diff --git a/internal/stores/postgres/user_store.go b/internal/stores/postgres/user_store.go deleted file mode 100644 index d5122bb..0000000 --- a/internal/stores/postgres/user_store.go +++ /dev/null @@ -1,198 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - _ "github.com/lib/pq" -) - -type UserStore struct { - db *sql.DB -} - -func NewUserStore(connStr string) (*UserStore, error) { - db, err := sql.Open("postgres", connStr) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - if err := autoMigrate(db); err != nil { - return nil, fmt.Errorf("failed to migrate: %w", err) - } - - return &UserStore{db: db}, nil -} - -func autoMigrate(db *sql.DB) error { - // Users table - usersTable := ` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - ` - - if _, err := db.Exec(usersTable); err != nil { - return fmt.Errorf("failed to create users table: %w", err) - } - - // Sessions table - sessionsTable := ` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - refresh_token TEXT NOT NULL, - lookup_hash TEXT UNIQUE NOT NULL, - created_at TIMESTAMP NOT NULL, - expires_at TIMESTAMP NOT NULL, - last_seen_at TIMESTAMP NOT NULL, - ip_address TEXT, - device_name TEXT, - device_type TEXT, - browser TEXT, - os TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_sessions_lookup ON sessions(lookup_hash); - CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); - CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); - ` - - if _, err := db.Exec(sessionsTable); err != nil { - return fmt.Errorf("failed to create sessions table: %w", err) - } - - return nil -} - -func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) { - query := ` - INSERT INTO users (id, email, password_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, email, password_hash, created_at, updated_at - ` - - now := time.Now() - id := fmt.Sprintf("usr_%d", time.Now().UnixNano()) - - var user core.User - err := s.db.QueryRowContext(ctx, query, - id, email, passwordHash, now, now, - ).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt) - - if err != nil { - if err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"` { - return nil, core.ErrUserExists - } - return nil, fmt.Errorf("failed to create user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) { - query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = $1` - - var user core.User - err := s.db.QueryRowContext(ctx, query, email).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) { - query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = $1` - - var user core.User - err := s.db.QueryRowContext(ctx, query, id).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error { - query := `UPDATE users SET email = $1, updated_at = $2 WHERE id = $3` - - result, err := s.db.ExecContext(ctx, query, newEmail, time.Now(), id) - if err != nil { - return fmt.Errorf("failed to update email: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error { - query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3` - - result, err := s.db.ExecContext(ctx, query, newPasswordHash, time.Now(), id) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) Delete(ctx context.Context, id string) error { - query := `DELETE FROM users WHERE id = $1` - - result, err := s.db.ExecContext(ctx, query, id) - if err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) Close() error { - if s.db != nil { - return s.db.Close() - } - return nil -} - -func (s *UserStore) GetDB() *sql.DB { - return s.db -} diff --git a/internal/stores/sqlite/session_store.go b/internal/stores/sqlite/session_store.go deleted file mode 100644 index b46d303..0000000 --- a/internal/stores/sqlite/session_store.go +++ /dev/null @@ -1,190 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - _ "github.com/mattn/go-sqlite3" -) - -type SessionStore struct { - db *sql.DB -} - -func NewSessionStore(db *sql.DB) *SessionStore { - return &SessionStore{db: db} -} - -// Create stores a new session with device info -func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) { - query := ` - INSERT INTO sessions (id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - ` - - id := fmt.Sprintf("sess_%d", time.Now().UnixNano()) - now := time.Now() - expiresAt := now.Add(7 * 24 * time.Hour) - - deviceName := "" - deviceType := "" - browser := "" - os := "" - - if device != nil { - deviceName = device.DeviceName - deviceType = device.DeviceType - browser = device.Browser - os = device.OS - } - - var session core.Session - err := s.db.QueryRowContext(ctx, query, - id, userID, refreshTokenHash, lookupHash, now, expiresAt, now, ipAddress, - deviceName, deviceType, browser, os, - ).Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.LookupHash, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - - return &session, nil -} - -// GetByRefreshToken finds session using lookup hash -func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) { - query := ` - SELECT id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - FROM sessions - WHERE lookup_hash = ? AND expires_at > ? - ` - - var session core.Session - err := s.db.QueryRowContext(ctx, query, lookupHash, time.Now()).Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.LookupHash, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrSessionNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get session: %w", err) - } - - return &session, nil -} - -// UpdateLastSeen updates the last seen time for a session -func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error { - query := `UPDATE sessions SET last_seen_at = ? WHERE id = ?` - - result, err := s.db.ExecContext(ctx, query, time.Now(), sessionID) - if err != nil { - return fmt.Errorf("failed to update last seen: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// Revoke removes a specific session -func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error { - query := `DELETE FROM sessions WHERE id = ?` - - result, err := s.db.ExecContext(ctx, query, sessionID) - if err != nil { - return fmt.Errorf("failed to revoke session: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrSessionNotFound - } - - return nil -} - -// RevokeAllForUser removes all sessions for a user -func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error { - query := `DELETE FROM sessions WHERE user_id = ?` - - _, err := s.db.ExecContext(ctx, query, userID) - if err != nil { - return fmt.Errorf("failed to revoke all sessions: %w", err) - } - - return nil -} - -// ListForUser returns all active sessions for a user -func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) { - query := ` - SELECT id, user_id, refresh_token, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os - FROM sessions - WHERE user_id = ? AND expires_at > ? - ORDER BY last_seen_at DESC - ` - - rows, err := s.db.QueryContext(ctx, query, userID, time.Now()) - if err != nil { - return nil, fmt.Errorf("failed to list sessions: %w", err) - } - defer rows.Close() - - var sessions []core.Session - for rows.Next() { - var session core.Session - err := rows.Scan( - &session.ID, - &session.UserID, - &session.RefreshToken, - &session.CreatedAt, - &session.ExpiresAt, - &session.LastSeenAt, - &session.IPAddress, - &session.DeviceName, - &session.DeviceType, - &session.Browser, - &session.OS, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan session: %w", err) - } - session.LookupHash = "" // Don't expose - sessions = append(sessions, session) - } - - return sessions, nil -} diff --git a/internal/stores/sqlite/user_store.go b/internal/stores/sqlite/user_store.go deleted file mode 100644 index a5e8a83..0000000 --- a/internal/stores/sqlite/user_store.go +++ /dev/null @@ -1,199 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/crydensync/cryden/internal/core" - _ "github.com/mattn/go-sqlite3" -) - -// UserStore implements core.UserStore with SQLite -type UserStore struct { - db *sql.DB -} - -// NewUserStore creates a new SQLite user store -func NewUserStore(dbPath string) (*UserStore, error) { - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - if err := autoMigrate(db); err != nil { - return nil, fmt.Errorf("failed to migrate: %w", err) - } - - return &UserStore{db: db}, nil -} - - -func autoMigrate(db *sql.DB) error { - // Users table - usersTable := ` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - ` - - if _, err := db.Exec(usersTable); err != nil { - return fmt.Errorf("failed to create users table: %w", err) - } - - // Sessions table - sessionsTable := ` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - refresh_token TEXT NOT NULL, - lookup_hash TEXT UNIQUE NOT NULL, - created_at TIMESTAMP NOT NULL, - expires_at TIMESTAMP NOT NULL, - last_seen_at TIMESTAMP NOT NULL, - ip_address TEXT, - device_name TEXT, - device_type TEXT, - browser TEXT, - os TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_sessions_lookup ON sessions(lookup_hash); - CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); - CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); - ` - - if _, err := db.Exec(sessionsTable); err != nil { - return fmt.Errorf("failed to create sessions table: %w", err) - } - - return nil -} - -func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) { - query := ` - INSERT INTO users (id, email, password_hash, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - RETURNING id, email, password_hash, created_at, updated_at - ` - - now := time.Now() - id := fmt.Sprintf("usr_%d", time.Now().UnixNano()) - - var user core.User - err := s.db.QueryRowContext(ctx, query, - id, email, passwordHash, now, now, - ).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt) - - if err != nil { - if err.Error() == "UNIQUE constraint failed: users.email" { - return nil, core.ErrUserExists - } - return nil, fmt.Errorf("failed to create user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) { - query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = ?` - - var user core.User - err := s.db.QueryRowContext(ctx, query, email).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) { - query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = ?` - - var user core.User - err := s.db.QueryRowContext(ctx, query, id).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt, - ) - - if err == sql.ErrNoRows { - return nil, core.ErrUserNotFound - } - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - return &user, nil -} - -func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error { - query := `UPDATE users SET email = ?, updated_at = ? WHERE id = ?` - - result, err := s.db.ExecContext(ctx, query, newEmail, time.Now(), id) - if err != nil { - return fmt.Errorf("failed to update email: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error { - query := `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?` - - result, err := s.db.ExecContext(ctx, query, newPasswordHash, time.Now(), id) - if err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -func (s *UserStore) Delete(ctx context.Context, id string) error { - query := `DELETE FROM users WHERE id = ?` - - result, err := s.db.ExecContext(ctx, query, id) - if err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return core.ErrUserNotFound - } - - return nil -} - -// GetDB returns the underlying database connection -func (s *UserStore) GetDB() *sql.DB { - return s.db -} - -func (s *UserStore) Close() error { - return s.db.Close() -} diff --git a/internal/tests/audit_file_test.go b/internal/tests/audit_file_test.go deleted file mode 100644 index ab92802..0000000 --- a/internal/tests/audit_file_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package tests - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" -) - -func TestFileAuditLogger(t *testing.T) { - // Create temp directory for test logs - tempDir := t.TempDir() - logPath := filepath.Join(tempDir, "audit.log") - - // Create file logger - logger, err := core.NewFileAuditLogger(logPath) - if err != nil { - t.Fatalf("Failed to create file logger: %v", err) - } - defer logger.Close() - - ctx := context.Background() - - // Log an entry - entry := core.AuditEntry{ - Timestamp: time.Now(), - UserID: "user_123", - Action: core.ActionSignInSuccess, - Status: "SUCCESS", - IPAddress: "192.168.1.1", - Metadata: map[string]interface{}{ - "user_agent": "Mozilla/5.0", - }, - } - - if err := logger.Log(ctx, entry); err != nil { - t.Fatalf("Failed to log entry: %v", err) - } - - // Read the log file - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("Failed to read log file: %v", err) - } - - // Parse JSON - var loggedEntry core.AuditEntry - if err := json.Unmarshal(data, &loggedEntry); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - // Verify fields - if loggedEntry.UserID != entry.UserID { - t.Errorf("Expected UserID %s, got %s", entry.UserID, loggedEntry.UserID) - } - if loggedEntry.Action != entry.Action { - t.Errorf("Expected Action %s, got %s", entry.Action, loggedEntry.Action) - } - if loggedEntry.IPAddress != entry.IPAddress { - t.Errorf("Expected IPAddress %s, got %s", entry.IPAddress, loggedEntry.IPAddress) - } - if loggedEntry.Status != entry.Status { - t.Errorf("Expected Status %s, got %s", entry.Status, loggedEntry.Status) - } -} - -func TestFileAuditLoggerWithEngine(t *testing.T) { - // Create temp directory - tempDir := t.TempDir() - logPath := filepath.Join(tempDir, "auth.log") - - // Create file logger - logger, err := core.NewFileAuditLogger(logPath) - if err != nil { - t.Fatalf("Failed to create logger: %v", err) - } - defer logger.Close() - - // Create engine with file logger - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - engine.WithAuditLogger(logger) - - ctx := context.Background() - - // Perform signup (should log) - _, err = engine.SignUp(ctx, "test@example.com", "Password123") - if err != nil { - t.Fatalf("SignUp failed: %v", err) - } - - // Check log file was written - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("Failed to read log file: %v", err) - } - - if len(data) == 0 { - t.Error("Log file is empty") - } - - // Verify JSON format - var entry core.AuditEntry - if err := json.Unmarshal(data, &entry); err != nil { - t.Errorf("Invalid JSON format: %v", err) - } - - // Verify it's a signup event - if entry.Action != core.ActionSignUp { - t.Errorf("Expected Action SIGN_UP, got %s", entry.Action) - } -} - -func TestFileAuditLoggerRotation(t *testing.T) { - tempDir := t.TempDir() - logPath := filepath.Join(tempDir, "rotate.log") - - logger, err := core.NewFileAuditLogger(logPath) - if err != nil { - t.Fatalf("Failed to create logger: %v", err) - } - defer logger.Close() - - ctx := context.Background() - - // Write first entry - err = logger.Log(ctx, core.AuditEntry{ - UserID: "user1", - Action: "TEST_ENTRY_1", - Status: "SUCCESS", - }) - if err != nil { - t.Fatalf("Failed to write first entry: %v", err) - } - - // Rotate the log file - if err := logger.Rotate(); err != nil { - t.Fatalf("Rotation failed: %v", err) - } - - // Write second entry after rotation - err = logger.Log(ctx, core.AuditEntry{ - UserID: "user2", - Action: "TEST_ENTRY_2", - Status: "SUCCESS", - }) - if err != nil { - t.Fatalf("Failed to write second entry: %v", err) - } - - // Read the log file - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatalf("Failed to read log file: %v", err) - } - - // Should have two JSON lines (one per line) - lines := 0 - for _, b := range data { - if b == '\n' { - lines++ - } - } - - if lines < 2 { - t.Errorf("Expected at least 2 log entries, got %d", lines) - } -} - -func TestFileAuditLoggerDirectoryCreation(t *testing.T) { - tempDir := t.TempDir() - // Create a nested path that doesn't exist yet - logPath := filepath.Join(tempDir, "nested", "deep", "path", "audit.log") - - logger, err := core.NewFileAuditLogger(logPath) - if err != nil { - t.Fatalf("Failed to create logger with nested directory: %v", err) - } - defer logger.Close() - - // Check that directory was created - if _, err := os.Stat(filepath.Dir(logPath)); os.IsNotExist(err) { - t.Error("Directory was not created") - } - - // Check that file exists - if _, err := os.Stat(logPath); os.IsNotExist(err) { - t.Error("Log file was not created") - } -} - -func TestMultipleFileLoggers(t *testing.T) { - tempDir := t.TempDir() - logPath1 := filepath.Join(tempDir, "log1.log") - logPath2 := filepath.Join(tempDir, "log2.log") - - // Create two separate loggers - logger1, err := core.NewFileAuditLogger(logPath1) - if err != nil { - t.Fatalf("Failed to create logger1: %v", err) - } - defer logger1.Close() - - logger2, err := core.NewFileAuditLogger(logPath2) - if err != nil { - t.Fatalf("Failed to create logger2: %v", err) - } - defer logger2.Close() - - ctx := context.Background() - - // Log to both - logger1.Log(ctx, core.AuditEntry{UserID: "user1", Action: "LOG1"}) - logger2.Log(ctx, core.AuditEntry{UserID: "user2", Action: "LOG2"}) - - // Verify both files have content - data1, _ := os.ReadFile(logPath1) - data2, _ := os.ReadFile(logPath2) - - if len(data1) == 0 { - t.Error("Logger1 file is empty") - } - if len(data2) == 0 { - t.Error("Logger2 file is empty") - } -} diff --git a/internal/tests/audit_test.go b/internal/tests/audit_test.go deleted file mode 100644 index ca7a14c..0000000 --- a/internal/tests/audit_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package tests - -import ( - "context" - "testing" - "time" - - "github.com/crydensync/cryden/internal/core" -) - -func TestConsoleAuditLogger(t *testing.T) { - logger := core.NewConsoleAuditLogger() - ctx := context.Background() - - t.Run("log entry", func(t *testing.T) { - entry := core.AuditEntry{ - Timestamp: time.Now(), - UserID: "user_123", - Action: core.ActionSignInSuccess, - Status: "SUCCESS", - IPAddress: "192.168.1.1", - UserAgent: "test-agent", - } - - err := logger.Log(ctx, entry) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) - - t.Run("log failed attempt", func(t *testing.T) { - entry := core.AuditEntry{ - Timestamp: time.Now(), - UserID: "user_123", - Action: core.ActionSignInFailed, - Status: "FAILED", - Error: "wrong password", - IPAddress: "192.168.1.1", - } - - err := logger.Log(ctx, entry) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) - - logger.Close() -} - -func TestNoopAuditLogger(t *testing.T) { - logger := core.NewNoopAuditLogger() - ctx := context.Background() - - t.Run("log does nothing", func(t *testing.T) { - entry := core.AuditEntry{ - Timestamp: time.Now(), - UserID: "user_123", - Action: core.ActionSignUp, - Status: "SUCCESS", - } - - err := logger.Log(ctx, entry) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) - - logger.Close() -} diff --git a/internal/tests/device_test.go b/internal/tests/device_test.go deleted file mode 100644 index 6c4f3a0..0000000 --- a/internal/tests/device_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package tests - -import ( - "context" - "testing" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" -) - -func TestParseUserAgent(t *testing.T) { - tests := []struct { - name string - userAgent string - expected core.DeviceInfo - }{ - { - name: "Chrome on Windows", - userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0", - expected: core.DeviceInfo{ - DeviceName: "Chrome on Windows 10", - DeviceType: "desktop", - Browser: "Chrome", - OS: "Windows 10", - }, - }, - { - name: "Safari on iPhone", - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1", - expected: core.DeviceInfo{ - DeviceName: "Safari on iOS", - DeviceType: "mobile", - Browser: "Safari", - OS: "iOS", - }, - }, - { - name: "Firefox on Mac", - userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0", - expected: core.DeviceInfo{ - DeviceName: "Firefox on macOS", - DeviceType: "desktop", - Browser: "Firefox", - OS: "macOS", - }, - }, - { - name: "Empty user agent", - userAgent: "", - expected: core.DeviceInfo{ - DeviceName: "Unknown", - DeviceType: "unknown", - Browser: "Unknown", - OS: "Unknown", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := core.ParseUserAgent(tt.userAgent) - - if result.DeviceName != tt.expected.DeviceName { - t.Errorf("DeviceName: expected %s, got %s", tt.expected.DeviceName, result.DeviceName) - } - if result.DeviceType != tt.expected.DeviceType { - t.Errorf("DeviceType: expected %s, got %s", tt.expected.DeviceType, result.DeviceType) - } - if result.Browser != tt.expected.Browser { - t.Errorf("Browser: expected %s, got %s", tt.expected.Browser, result.Browser) - } - if result.OS != tt.expected.OS { - t.Errorf("OS: expected %s, got %s", tt.expected.OS, result.OS) - } - }) - } -} - -func TestDeviceTrackingInSession(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - - ctx := context.Background() - - // Sign up - _, err := engine.SignUp(ctx, "device@example.com", "Password123") - if err != nil { - t.Fatalf("SignUp failed: %v", err) - } - - // Login with device info - userAgent := "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1" - deviceInfo := core.ParseUserAgent(userAgent) - ipAddress := "192.168.1.100" - - tokens, _, err := engine.Login(ctx, "device@example.com", "Password123", deviceInfo, ipAddress) - if err != nil { - t.Fatalf("Login failed: %v", err) - } - - // List sessions - sessions, err := engine.ListSessions(ctx, "device@example.com") - if err != nil { - t.Fatalf("ListSessions failed: %v", err) - } - - if len(sessions) == 0 { - t.Fatal("No sessions found") - } - - session := sessions[0] - if session.DeviceName != "Safari on iOS" { - t.Errorf("Expected device name 'Safari on iOS', got '%s'", session.DeviceName) - } - if session.DeviceType != "mobile" { - t.Errorf("Expected device type 'mobile', got '%s'", session.DeviceType) - } - if session.IPAddress != ipAddress { - t.Errorf("Expected IP %s, got %s", ipAddress, session.IPAddress) - } -} diff --git a/internal/tests/engine_test.go b/internal/tests/engine_test.go deleted file mode 100644 index 7f808a9..0000000 --- a/internal/tests/engine_test.go +++ /dev/null @@ -1,482 +0,0 @@ -// tests/engine_test.go -package tests - -import ( - "context" - "testing" - "time" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" -) - -func TestSignUp(t *testing.T) { - // Setup - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - t.Run("valid signup", func(t *testing.T) { - user, err := engine.SignUp(ctx, "test@example.com", "Password123") - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if user == nil { - t.Fatal("Expected user, got nil") - } - - if user.Email != "test@example.com" { - t.Errorf("Expected email test@example.com, got %s", user.Email) - } - - if user.ID == "" { - t.Error("Expected user ID to be set") - } - }) - - t.Run("duplicate email", func(t *testing.T) { - // First signup - _, err := engine.SignUp(ctx, "duplicate@example.com", "Password123") - if err != nil { - t.Fatalf("First signup failed: %v", err) - } - - // Second signup with same email - _, err = engine.SignUp(ctx, "duplicate@example.com", "Password123") - if err != core.ErrUserExists { - t.Errorf("Expected ErrUserExists, got %v", err) - } - }) - - t.Run("invalid email", func(t *testing.T) { - testCases := []struct { - email string - password string - wantErr error - }{ - {"", "Password123", core.ErrInvalidEmail}, - {"notanemail", "Password123", core.ErrInvalidEmail}, - {"@domain.com", "Password123", core.ErrInvalidEmail}, - {"user@", "Password123", core.ErrInvalidEmail}, - {"user@domain", "Password123", core.ErrInvalidEmail}, - } - - for _, tc := range testCases { - _, err := engine.SignUp(ctx, tc.email, tc.password) - if err == nil { - t.Errorf("Expected error for email %q, got nil", tc.email) - continue - } - - // Check if it's a ValidationError - if verr, ok := err.(*core.ValidationError); ok { - if verr.Field != "email" { - t.Errorf("Expected field 'email', got %q", verr.Field) - } - } else { - t.Errorf("Expected ValidationError, got %T", err) - } - } - }) - - t.Run("invalid password", func(t *testing.T) { - testCases := []struct { - password string - wantErr error - }{ - {"short", core.ErrPasswordTooShort}, - {"onlylowercase", core.ErrPasswordNoUpper}, - {"ONLYUPPERCASE", core.ErrPasswordNoLower}, - {"NoNumbers", core.ErrPasswordNoNumber}, - } - - for _, tc := range testCases { - _, err := engine.SignUp(ctx, "test@example.com", tc.password) - if err == nil { - t.Errorf("Expected error for password %q, got nil", tc.password) - continue - } - - if verr, ok := err.(*core.ValidationError); ok { - if verr.Field != "password" { - t.Errorf("Expected field 'password', got %q", verr.Field) - } - } - } - }) -} - -func TestLogin(t *testing.T) { - // Setup - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Create a test user - _, err := engine.SignUp(ctx, "login@example.com", "Password123") - if err != nil { - t.Fatalf("Failed to create test user: %v", err) - } - - t.Run("valid login", func(t *testing.T) { - tokens, _, err := engine.Login(ctx, "login@example.com", "Password123") - - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - if tokens == nil { - t.Fatal("Expected tokens, got nil") - } - - if tokens.AccessToken == "" { - t.Error("Expected access token, got empty") - } - - if tokens.RefreshToken == "" { - t.Error("Expected refresh token, got empty") - } - }) - - t.Run("wrong password", func(t *testing.T) { - _, _, err := engine.Login(ctx, "login@example.com", "wrongpassword") - - if err != core.ErrInvalidCredentials { - t.Errorf("Expected ErrInvalidCredentials, got %v", err) - } - }) - - t.Run("nonexistent user", func(t *testing.T) { - _, _, err := engine.Login(ctx, "nonexistent@example.com", "Password123") - - if err != core.ErrInvalidCredentials { - t.Errorf("Expected ErrInvalidCredentials, got %v", err) - } - }) -} - -func TestLoginRateLimiting(t *testing.T) { - // Setup - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - ctx := context.Background() - - // Create engine with strict rate limit (2 per minute) - engine := core.New(userStore, sessionStore) - limiter := core.NewMemoryRateLimiter(2, time.Minute) - engine.WithRateLimiter(limiter) - - // Create a test user - _, err := engine.SignUp(ctx, "ratelimit@example.com", "Password123") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - t.Run("first two attempts allowed", func(t *testing.T) { - // First attempt - _, result, err := engine.Login(ctx, "ratelimit@example.com", "Password123") - if err != nil { - t.Errorf("First login failed: %v", err) - } - if result.Remaining != 1 { - t.Errorf("Expected remaining 1, got %d", result.Remaining) - } - - // Second attempt - _, result, err = engine.Login(ctx, "ratelimit@example.com", "Password123") - if err != nil { - t.Errorf("Second login failed: %v", err) - } - if result.Remaining != 0 { - t.Errorf("Expected remaining 0, got %d", result.Remaining) - } - }) - - t.Run("third attempt blocked", func(t *testing.T) { - _, result, err := engine.Login(ctx, "ratelimit@example.com", "Password123") - if err != core.ErrTooManyAttempts { - t.Errorf("Expected ErrTooManyAttempts, got %v", err) - } - if result.Allowed { - t.Error("Expected not allowed") - } - }) -} - -func TestLogout(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - engine.SignUp(ctx, "logout@example.com", "Password123") - tokens, _, _ := engine.Login(ctx, "logout@example.com", "Password123") - - t.Run("valid logout", func(t *testing.T) { - err := engine.Logout(ctx, tokens.RefreshToken) - if err != nil { - t.Errorf("Logout failed: %v", err) - } - - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken) - if err != core.ErrSessionNotFound { - t.Errorf("Expected session not found, got %v", err) - } - }) - - t.Run("invalid token", func(t *testing.T) { - err := engine.Logout(ctx, "invalid") - if err != core.ErrInvalidToken { - t.Errorf("Expected ErrInvalidToken, got %v", err) - } - }) -} - -func TestLogoutAll(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - engine.SignUp(ctx, "logoutall@example.com", "Password123") - - // Create multiple sessions - tokens1, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123") - tokens2, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123") - tokens3, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123") - - t.Run("logout all devices", func(t *testing.T) { - user, _ := engine.GetUserStore().GetByEmail(ctx, "logoutall@example.com") - err := engine.LogoutAll(ctx, user.ID) - if err != nil { - t.Errorf("LogoutAll failed: %v", err) - } - - // All sessions should be gone - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens1.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("First session still exists") - } - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens2.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("Second session still exists") - } - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens3.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("Third session still exists") - } - }) -} - -func TestChangePassword(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Create user and login - engine.SignUp(ctx, "pass@example.com", "OldPass123") - user, _ := engine.GetUserStore().GetByEmail(ctx, "pass@example.com") - tokens, _, _ := engine.Login(ctx, "pass@example.com", "OldPass123") - - t.Run("successful password change", func(t *testing.T) { - err := engine.ChangePassword(ctx, user.ID, "OldPass123", "NewPass456") - if err != nil { - t.Errorf("ChangePassword failed: %v", err) - } - - // Old password should not work - _, _, err = engine.Login(ctx, "pass@example.com", "OldPass123") - if err != core.ErrInvalidCredentials { - t.Error("Old password still works") - } - - // New password should work - _, _, err = engine.Login(ctx, "pass@example.com", "NewPass456") - if err != nil { - t.Error("New password doesn't work") - } - - // Old session should be revoked - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("Old session still exists") - } - }) - - t.Run("wrong old password", func(t *testing.T) { - err := engine.ChangePassword(ctx, user.ID, "WrongPass", "NewPass456") - if err != core.ErrInvalidCredentials { - t.Errorf("Expected ErrInvalidCredentials, got %v", err) - } - }) - - t.Run("invalid new password", func(t *testing.T) { - err := engine.ChangePassword(ctx, user.ID, "NewPass456", "short") - if err == nil { - t.Error("Expected validation error, got nil") - } - }) -} - -func TestChangeEmail(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Create user - engine.SignUp(ctx, "old@example.com", "Password123") - user, _ := engine.GetUserStore().GetByEmail(ctx, "old@example.com") - - t.Run("successful email change", func(t *testing.T) { - err := engine.ChangeEmail(ctx, user.ID, "new@example.com") - if err != nil { - t.Errorf("ChangeEmail failed: %v", err) - } - - // Old email should not work - _, err = engine.GetUserStore().GetByEmail(ctx, "old@example.com") - if err != core.ErrUserNotFound { - t.Error("Old email still exists") - } - - // New email should work - found, err := engine.GetUserStore().GetByEmail(ctx, "new@example.com") - if err != nil { - t.Error("New email not found") - } - if found.ID != user.ID { - t.Error("Wrong user returned") - } - }) - - t.Run("duplicate email", func(t *testing.T) { - engine.SignUp(ctx, "duplicate@example.com", "Password123") - duplicate, _ := engine.GetUserStore().GetByEmail(ctx, "duplicate@example.com") - - err := engine.ChangeEmail(ctx, duplicate.ID, "new@example.com") - if err != core.ErrUserExists { - t.Errorf("Expected ErrUserExists, got %v", err) - } - }) - - t.Run("invalid email", func(t *testing.T) { - err := engine.ChangeEmail(ctx, user.ID, "notanemail") - if err == nil { - t.Error("Expected validation error, got nil") - } - }) -} - -func TestDeleteAccount(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Create user and sessions - engine.SignUp(ctx, "delete@example.com", "Password123") - user, _ := engine.GetUserStore().GetByEmail(ctx, "delete@example.com") - - // Create multiple sessions - tokens1, _, _ := engine.Login(ctx, "delete@example.com", "Password123") - tokens2, _, _ := engine.Login(ctx, "delete@example.com", "Password123") - - t.Run("delete account", func(t *testing.T) { - err := engine.DeleteAccount(ctx, user.ID) - if err != nil { - t.Errorf("DeleteAccount failed: %v", err) - } - - // User should be gone - _, err = engine.GetUserStore().GetByEmail(ctx, "delete@example.com") - if err != core.ErrUserNotFound { - t.Error("User still exists") - } - - // Sessions should be gone - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens1.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("First session still exists") - } - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens2.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("Second session still exists") - } - }) - - t.Run("delete nonexistent user", func(t *testing.T) { - err := engine.DeleteAccount(ctx, "nonexistent") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound, got %v", err) - } - }) -} - -func TestRefreshToken(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Create user and login - engine.SignUp(ctx, "refresh@example.com", "Password123") - tokens, _, _ := engine.Login(ctx, "refresh@example.com", "Password123") - - t.Run("successful refresh", func(t *testing.T) { - newTokens, err := engine.RefreshToken(ctx, tokens.RefreshToken) - if err != nil { - t.Errorf("Refresh failed: %v", err) - } - - if newTokens.AccessToken == tokens.AccessToken { - t.Error("Access token should be new") - } - if newTokens.RefreshToken == tokens.RefreshToken { - t.Error("Refresh token should be new") - } - - // Old refresh token should be revoked - _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken) - if err != core.ErrSessionNotFound { - t.Error("Old session still exists") - } - - // New refresh token should work - _, err = engine.GetSessionByRefreshToken(ctx, newTokens.RefreshToken) - if err != nil { - t.Error("New session not found") - } - }) - - t.Run("refresh with invalid token", func(t *testing.T) { - _, err := engine.RefreshToken(ctx, "invalid") - if err != core.ErrInvalidToken { - t.Errorf("Expected ErrInvalidToken, got %v", err) - } - }) - - t.Run("refresh with already used token", func(t *testing.T) { - // First refresh - works - newTokens, _ := engine.RefreshToken(ctx, tokens.RefreshToken) - - // Second refresh with same token - should fail (already revoked) - _, err := engine.RefreshToken(ctx, tokens.RefreshToken) - if err != core.ErrInvalidToken { - t.Errorf("Expected ErrInvalidToken for used token, got %v", err) - } - - if newTokens != nil { - // New token should still work - _, err = engine.RefreshToken(ctx, newTokens.RefreshToken) - if err != nil { - t.Error("New token should work") - } - } - }) -} diff --git a/internal/tests/hasher_test.go b/internal/tests/hasher_test.go deleted file mode 100644 index 8f2ca73..0000000 --- a/internal/tests/hasher_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package tests - -import ( - "github.com/crydensync/cryden/internal/core" - "testing" -) - -func TestBcryptHasher(t *testing.T) { - hasher := core.NewBcryptHasher(4) - - t.Run("hash and compare", func(t *testing.T) { - password := "Test123" - - hash, err := hasher.Hash(password) - if err != nil { - t.Fatalf("Hash failed: %v", err) - } - - err = hasher.Compare(password, hash) - if err != nil { - t.Errorf("Compare failed: %v", err) - } - }) - - t.Run("wrong password fails", func(t *testing.T) { - hash, _ := hasher.Hash("correct") - - err := hasher.Compare("wrong", hash) - if err == nil { - t.Error("Expected error, got nil") - } - }) -} - -func TestMockHasher(t *testing.T) { - hasher := &core.MockHasher{} - - t.Run("mock hash returns password", func(t *testing.T) { - hash, _ := hasher.Hash("test") - if hash != "test" { - t.Errorf("Expected 'test', got '%s'", hash) - } - }) - - t.Run("mock compare works", func(t *testing.T) { - err := hasher.Compare("test", "test") - if err != nil { - t.Errorf("Compare failed: %v", err) - } - }) -} diff --git a/internal/tests/jwt_test.go b/internal/tests/jwt_test.go deleted file mode 100644 index 4966cfe..0000000 --- a/internal/tests/jwt_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package tests - -import ( - "github.com/crydensync/cryden/internal/core" - "github.com/golang-jwt/jwt/v5" - "testing" - "time" -) - -func TestJWTTokens(t *testing.T) { - engine := core.New(nil, nil) - engine.WithJWTSecret("test-secret") - - t.Run("generate and verify token", func(t *testing.T) { - // Since generateTokens needs a session store, we'll test the JWT part separately - userID := "user_123" - - // Manually create claims - claims := core.Claims{ - UserID: userID, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), - Issuer: "cryden", - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte("test-secret")) - if err != nil { - t.Fatalf("Failed to sign token: %v", err) - } - - // Verify - verifiedClaims, err := engine.VerifyToken(tokenString) - if err != nil { - t.Fatalf("Failed to verify token: %v", err) - } - - if verifiedClaims.UserID != userID { - t.Errorf("Expected userID %s, got %s", userID, verifiedClaims.UserID) - } - }) -} diff --git a/internal/tests/rate_limiter_test.go b/internal/tests/rate_limiter_test.go deleted file mode 100644 index 8eaf832..0000000 --- a/internal/tests/rate_limiter_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package tests - -import ( - "context" - "testing" - "time" - - "github.com/crydensync/cryden/internal/core" -) - -func TestMemoryRateLimiter(t *testing.T) { - limiter := core.NewMemoryRateLimiter(3, time.Second) - ctx := context.Background() - key := "test-ip-123" - - t.Run("allows within limit", func(t *testing.T) { - // First 3 attempts should be allowed - for i := 0; i < 3; i++ { - result, err := limiter.Allow(ctx, key) - if err != nil { - t.Fatalf("Allow failed: %v", err) - } - if !result.Allowed { - t.Errorf("Attempt %d should be allowed", i+1) - } - if result.Remaining != 3-(i+1) { - t.Errorf("Expected remaining %d, got %d", 3-(i+1), result.Remaining) - } - } - }) - - t.Run("blocks after limit", func(t *testing.T) { - // 4th attempt should be blocked - result, err := limiter.Allow(ctx, key) - if err != nil { - t.Fatalf("Allow failed: %v", err) - } - if result.Allowed { - t.Error("Attempt should be blocked") - } - if result.Remaining != 0 { - t.Errorf("Expected remaining 0, got %d", result.Remaining) - } - if result.Reset <= 0 { - t.Error("Expected positive reset time") - } - }) - - t.Run("reset works", func(t *testing.T) { - err := limiter.Reset(ctx, key) - if err != nil { - t.Fatalf("Reset failed: %v", err) - } - - result, err := limiter.Allow(ctx, key) - if err != nil { - t.Fatalf("Allow failed: %v", err) - } - if !result.Allowed { - t.Error("After reset, should be allowed") - } - }) - - t.Run("different keys are independent", func(t *testing.T) { - key1 := "ip-1" - key2 := "ip-2" - - // Use up key1 - for i := 0; i < 3; i++ { - limiter.Allow(ctx, key1) - } - - // key1 should be blocked - result1, _ := limiter.Allow(ctx, key1) - if result1.Allowed { - t.Error("key1 should be blocked") - } - - // key2 should still work - result2, _ := limiter.Allow(ctx, key2) - if !result2.Allowed { - t.Error("key2 should be allowed") - } - }) -} - -func TestNoopRateLimiter(t *testing.T) { - limiter := &core.NoopRateLimiter{} - ctx := context.Background() - - t.Run("always allows", func(t *testing.T) { - for i := 0; i < 100; i++ { - result, err := limiter.Allow(ctx, "any-key") - if err != nil { - t.Fatalf("Allow failed: %v", err) - } - if !result.Allowed { - t.Error("Noop should always allow") - } - } - }) -} diff --git a/internal/tests/store_test.go b/internal/tests/store_test.go deleted file mode 100644 index 3fc21cf..0000000 --- a/internal/tests/store_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package tests - -import ( - "context" - "os" - "testing" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" - "github.com/crydensync/cryden/internal/stores/mongodb" - "github.com/crydensync/cryden/internal/stores/postgres" - "github.com/crydensync/cryden/internal/stores/sqlite" -) - -// TestUserStore runs the same tests for ALL implementations -func TestUserStore(t *testing.T) { - // Run tests for each store implementation - t.Run("Memory", func(t *testing.T) { - store := memory.NewUserStore() - defer store.Close() - testUserStore(t, store) - }) - - t.Run("SQLite", func(t *testing.T) { - dbPath := "test_sqlite.db" - defer os.Remove(dbPath) - - store, err := sqlite.NewUserStore(dbPath) - if err != nil { - t.Fatalf("Failed to create SQLite store: %v", err) - } - defer store.Close() - - testUserStore(t, store) - }) - - t.Run("PostgreSQL", func(t *testing.T) { - // Use environment variable for connection string - connStr := os.Getenv("TEST_POSTGRES_URI") - if connStr == "" { - t.Skip("Skipping PostgreSQL test: TEST_POSTGRES_URI not set") - } - - store, err := postgres.NewUserStore(connStr) - if err != nil { - t.Fatalf("Failed to create PostgreSQL store: %v", err) - } - defer store.Close() - - testUserStore(t, store) - }) - - t.Run("MongoDB", func(t *testing.T) { - // Use environment variable for connection string - uri := os.Getenv("TEST_MONGODB_URI") - if uri == "" { - t.Skip("Skipping MongoDB test: TEST_MONGODB_URI not set") - } - - store, err := mongodb.NewUserStore(uri, "test_db") - if err != nil { - t.Fatalf("Failed to create MongoDB store: %v", err) - } - defer store.Close() - - testUserStore(t, store) - }) -} - -// testUserStore contains the actual tests -func testUserStore(t *testing.T, store core.UserStore) { - ctx := context.Background() - - t.Run("Create and Get user", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "test@example.com", "hash123") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - if user.Email != "test@example.com" { - t.Errorf("Expected email test@example.com, got %s", user.Email) - } - - if user.ID == "" { - t.Error("Expected user ID to be set") - } - - // Get by email - found, err := store.GetByEmail(ctx, "test@example.com") - if err != nil { - t.Fatalf("Failed to get by email: %v", err) - } - - if found.ID != user.ID { - t.Errorf("Expected ID %s, got %s", user.ID, found.ID) - } - - // Get by ID - found, err = store.GetByID(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to get by ID: %v", err) - } - - if found.Email != user.Email { - t.Errorf("Expected email %s, got %s", user.Email, found.Email) - } - }) - - t.Run("Duplicate email", func(t *testing.T) { - // First creation - _, err := store.Create(ctx, "duplicate@example.com", "hash1") - if err != nil { - t.Fatalf("First create failed: %v", err) - } - - // Second creation with same email - _, err = store.Create(ctx, "duplicate@example.com", "hash2") - if err != core.ErrUserExists { - t.Errorf("Expected ErrUserExists, got %v", err) - } - }) - - t.Run("Get nonexistent user", func(t *testing.T) { - _, err := store.GetByEmail(ctx, "nonexistent@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound, got %v", err) - } - }) - - t.Run("Update email", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "old@example.com", "hash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Update email - err = store.UpdateEmail(ctx, user.ID, "new@example.com") - if err != nil { - t.Fatalf("Failed to update email: %v", err) - } - - // Try old email - _, err = store.GetByEmail(ctx, "old@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound for old email, got %v", err) - } - - // Try new email - found, err := store.GetByEmail(ctx, "new@example.com") - if err != nil { - t.Fatalf("Failed to get by new email: %v", err) - } - - if found.ID != user.ID { - t.Errorf("Expected user ID %s, got %s", user.ID, found.ID) - } - }) - - t.Run("Update password", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "pass@example.com", "oldhash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Update password - err = store.UpdatePassword(ctx, user.ID, "newhash") - if err != nil { - t.Fatalf("Failed to update password: %v", err) - } - - // Get and verify - found, err := store.GetByID(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to get user: %v", err) - } - - if found.PasswordHash != "newhash" { - t.Errorf("Expected newhash, got %s", found.PasswordHash) - } - }) - - t.Run("Delete user", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "delete@example.com", "hash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Delete user - err = store.Delete(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to delete user: %v", err) - } - - // Try to get by email - _, err = store.GetByEmail(ctx, "delete@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound after delete, got %v", err) - } - }) -} diff --git a/internal/tests/token_hashing_test.go b/internal/tests/token_hashing_test.go deleted file mode 100644 index 102a0eb..0000000 --- a/internal/tests/token_hashing_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package tests - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "testing" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" - "github.com/crydensync/cryden/internal/token" -) - -func TestTokenHashing(t *testing.T) { - t.Run("generate and verify refresh token", func(t *testing.T) { - hasher := core.NewBcryptHasher(10) - svc := token.NewService(hasher) - - // Generate token - bundle, err := svc.GenerateRefreshToken() - if err != nil { - t.Fatalf("Failed to generate token: %v", err) - } - - // Check all parts present - if bundle.PlainText == "" { - t.Error("Plain text token empty") - } - if bundle.LookupHash == "" { - t.Error("Lookup hash empty") - } - if bundle.StorageHash == "" { - t.Error("Storage hash empty") - } - - // Verify lookup hash is correct SHA256 - expectedLookup := sha256.Sum256([]byte(bundle.PlainText)) - if bundle.LookupHash != hex.EncodeToString(expectedLookup[:]) { - t.Error("Lookup hash doesn't match plain token") - } - - // Verify storage hash works - if err := svc.VerifyRefreshToken(bundle.PlainText, bundle.StorageHash); err != nil { - t.Error("Storage hash failed to verify plain token") - } - - // Wrong token should fail - if err := svc.VerifyRefreshToken("wrong-token", bundle.StorageHash); err == nil { - t.Error("Verification should fail for wrong token") - } - }) -} - -func TestSessionStoreWithHashedTokens(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - hasher := core.NewBcryptHasher(10) - tokenSvc := token.NewService(hasher) - - ctx := context.Background() - - // Create a test user - user, err := userStore.Create(ctx, "test@example.com", "password-hash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - t.Run("create and retrieve session", func(t *testing.T) { - // Generate token - bundle, err := tokenSvc.GenerateRefreshToken() - if err != nil { - t.Fatalf("Failed to generate token: %v", err) - } - - // Create session - _, err = sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash) - if err != nil { - t.Fatalf("Failed to create session: %v", err) - } - - // Retrieve by lookup hash - found, err := sessionStore.GetByRefreshToken(ctx, bundle.LookupHash) - if err != nil { - t.Fatalf("Failed to get session: %v", err) - } - - // Verify token - if err := tokenSvc.VerifyRefreshToken(bundle.PlainText, found.RefreshToken); err != nil { - t.Error("Failed to verify token") - } - - // Wrong lookup hash should fail - _, err = sessionStore.GetByRefreshToken(ctx, "wrong-hash") - if err != core.ErrSessionNotFound { - t.Error("Should not find session with wrong lookup hash") - } - }) - - t.Run("list sessions doesn't expose lookup hash", func(t *testing.T) { - // Create a session - bundle, _ := tokenSvc.GenerateRefreshToken() - sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash) - - // List sessions - sessions, err := sessionStore.ListForUser(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to list sessions: %v", err) - } - - // Check lookup hash is empty in list response - for _, s := range sessions { - if s.LookupHash != "" { - t.Error("Lookup hash should not be exposed in ListForUser") - } - } - }) - - t.Run("revoke session", func(t *testing.T) { - // Create session - bundle, _ := tokenSvc.GenerateRefreshToken() - session, err := sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash) - if err != nil { - t.Fatalf("Failed to create session: %v", err) - } - - // Revoke it - err = sessionStore.Revoke(ctx, session.ID) - if err != nil { - t.Fatalf("Failed to revoke session: %v", err) - } - - // Should not be findable - _, err = sessionStore.GetByRefreshToken(ctx, bundle.LookupHash) - if err != core.ErrSessionNotFound { - t.Error("Session still exists after revoke") - } - }) -} - -func TestFullLoginFlowWithHashedTokens(t *testing.T) { - userStore := memory.NewUserStore() - sessionStore := memory.NewSessionStore() - - // Create engine - engine := core.New(userStore, sessionStore) - ctx := context.Background() - - // Sign up - user, err := engine.SignUp(ctx, "flow@example.com", "Password123") - if err != nil { - t.Fatalf("SignUp failed: %v", err) - } - - t.Run("login stores hashed token", func(t *testing.T) { - // Login - tokens, _, err := engine.Login(ctx, "flow@example.com", "Password123") - if err != nil { - t.Fatalf("Login failed: %v", err) - } - - // Try to find session by looking up with SHA256 - sha := sha256.Sum256([]byte(tokens.RefreshToken)) - lookupHash := hex.EncodeToString(sha[:]) - - session, err := sessionStore.GetByRefreshToken(ctx, lookupHash) - if err != nil { - t.Fatalf("Failed to find session: %v", err) - } - - // Verify user ID matches - if session.UserID != user.ID { - t.Errorf("Expected user ID %s, got %s", user.ID, session.UserID) - } - - // Verify token in DB is not plain text - if session.RefreshToken == tokens.RefreshToken { - t.Error("Refresh token stored in plain text!") - } - }) - - t.Run("refresh token rotation", func(t *testing.T) { - // Login - tokens, _, err := engine.Login(ctx, "flow@example.com", "Password123") - if err != nil { - t.Fatalf("Login failed: %v", err) - } - - oldPlainToken := tokens.RefreshToken - oldLookup := sha256.Sum256([]byte(oldPlainToken)) - oldLookupHash := hex.EncodeToString(oldLookup[:]) - - // Refresh - newTokens, err := engine.RefreshToken(ctx, oldPlainToken) - if err != nil { - t.Fatalf("Refresh failed: %v", err) - } - - // Old token should be invalid - _, err = sessionStore.GetByRefreshToken(ctx, oldLookupHash) - if err != core.ErrSessionNotFound { - t.Error("Old token still valid after refresh") - } - - // New token should work - newLookup := sha256.Sum256([]byte(newTokens.RefreshToken)) - newLookupHash := hex.EncodeToString(newLookup[:]) - - session, err := sessionStore.GetByRefreshToken(ctx, newLookupHash) - if err != nil { - t.Error("New token not working") - } - - // Verify new token hash matches - if err := engine.GetHasher().Compare(newTokens.RefreshToken, session.RefreshToken); err != nil { - t.Error("New token verification failed") - } - }) -} diff --git a/internal/tests/user_store_test.go b/internal/tests/user_store_test.go deleted file mode 100644 index e339ee3..0000000 --- a/internal/tests/user_store_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package tests - -import ( - "context" - "testing" - - "github.com/crydensync/cryden/internal/core" - "github.com/crydensync/cryden/internal/stores/memory" -) - -func TestMemoryUserStore(t *testing.T) { - // Create store - store := memory.NewUserStore() - ctx := context.Background() - - t.Run("Create and Get user", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "test@example.com", "hash123") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - if user.Email != "test@example.com" { - t.Errorf("Expected email test@example.com, got %s", user.Email) - } - - if user.PasswordHash != "hash123" { - t.Errorf("Expected hash hash123, got %s", user.PasswordHash) - } - - // Get by email - found, err := store.GetByEmail(ctx, "test@example.com") - if err != nil { - t.Fatalf("Failed to get user by email: %v", err) - } - - if found.ID != user.ID { - t.Errorf("Expected ID %s, got %s", user.ID, found.ID) - } - - // Get by ID - found, err = store.GetByID(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to get user by ID: %v", err) - } - - if found.Email != user.Email { - t.Errorf("Expected email %s, got %s", user.Email, found.Email) - } - }) - - t.Run("Create duplicate user", func(t *testing.T) { - // First creation should work - _, err := store.Create(ctx, "duplicate@example.com", "hash") - if err != nil { - t.Fatalf("First creation failed: %v", err) - } - - // Second creation with same email should fail - _, err = store.Create(ctx, "duplicate@example.com", "hash") - if err != core.ErrUserExists { - t.Errorf("Expected ErrUserExists, got %v", err) - } - }) - - t.Run("Get nonexistent user", func(t *testing.T) { - _, err := store.GetByEmail(ctx, "nonexistent@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound, got %v", err) - } - }) - - t.Run("Update email", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "old@example.com", "hash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Update email - err = store.UpdateEmail(ctx, user.ID, "new@example.com") - if err != nil { - t.Fatalf("Failed to update email: %v", err) - } - - // Check old email doesn't work - _, err = store.GetByEmail(ctx, "old@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound for old email, got %v", err) - } - - // Check new email works - found, err := store.GetByEmail(ctx, "new@example.com") - if err != nil { - t.Fatalf("Failed to get by new email: %v", err) - } - - if found.ID != user.ID { - t.Errorf("Expected user ID %s, got %s", user.ID, found.ID) - } - }) - - t.Run("Update password", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "pass@example.com", "oldhash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Update password - err = store.UpdatePassword(ctx, user.ID, "newhash") - if err != nil { - t.Fatalf("Failed to update password: %v", err) - } - - // Get and verify - found, err := store.GetByID(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to get user: %v", err) - } - - if found.PasswordHash != "newhash" { - t.Errorf("Expected newhash, got %s", found.PasswordHash) - } - }) - - t.Run("Delete user", func(t *testing.T) { - // Create user - user, err := store.Create(ctx, "delete@example.com", "hash") - if err != nil { - t.Fatalf("Failed to create user: %v", err) - } - - // Delete user - err = store.Delete(ctx, user.ID) - if err != nil { - t.Fatalf("Failed to delete user: %v", err) - } - - // Try to get by email - _, err = store.GetByEmail(ctx, "delete@example.com") - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound after delete, got %v", err) - } - - // Try to get by ID - _, err = store.GetByID(ctx, user.ID) - if err != core.ErrUserNotFound { - t.Errorf("Expected ErrUserNotFound after delete, got %v", err) - } - }) -} diff --git a/internal/token/service.go b/internal/token/service.go deleted file mode 100644 index 11a7259..0000000 --- a/internal/token/service.go +++ /dev/null @@ -1,64 +0,0 @@ -package token - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - - "github.com/crydensync/cryden/internal/core" -) - -// Service handles all token operations -type Service struct { - hasher core.Hasher -} - -// NewService creates a new token service -func NewService(hasher core.Hasher) *Service { - return &Service{ - hasher: hasher, - } -} - -// RefreshTokenBundle contains all forms of a refresh token -type RefreshTokenBundle struct { - PlainText string // What client receives - LookupHash string // SHA256 for DB lookup (indexed) - StorageHash string // bcrypt for DB storage (salted) -} - -// GenerateRefreshToken creates a secure random token and its hashes -func (s *Service) GenerateRefreshToken() (*RefreshTokenBundle, error) { - // 1. Generate cryptographically secure random token (32 bytes = 256 bits) - token := make([]byte, 32) - _, err := rand.Read(token) - if err != nil { - return nil, fmt.Errorf("failed to generate random token: %w", err) - } - - // Encode as URL-safe base64 (no + or /, no padding) - plainToken := base64.RawURLEncoding.EncodeToString(token) - - // 2. Generate SHA256 lookup hash (for fast DB indexing) - sha := sha256.Sum256([]byte(plainToken)) - lookupHash := hex.EncodeToString(sha[:]) - - // 3. Generate bcrypt storage hash (for secure verification) - storageHash, err := s.hasher.Hash(plainToken) - if err != nil { - return nil, fmt.Errorf("failed to hash token: %w", err) - } - - return &RefreshTokenBundle{ - PlainText: plainToken, - LookupHash: lookupHash, - StorageHash: storageHash, - }, nil -} - -// VerifyRefreshToken checks if a plain token matches a stored hash -func (s *Service) VerifyRefreshToken(plainToken, storageHash string) error { - return s.hasher.Compare(plainToken, storageHash) -} From 6439dec7a0eafccc5e3baa40347f331237338a22 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Sun, 26 Jul 2026 22:47:17 +0100 Subject: [PATCH 02/18] chore: deleted: .github/workflows/go.yml deleted: .github/workflows/release.yml --- .github/workflows/go.yml | 28 ---------------------------- .github/workflows/release.yml | 33 --------------------------------- 2 files changed, 61 deletions(-) delete mode 100644 .github/workflows/go.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 3d2a330..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.21' - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./internal/tests/... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 635b9d7..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.21' - - - name: Run tests - run: go test -v ./internal/tests/... - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - name: Release ${{ github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 560d81d7e21b42e29aa3e4c4472029f4a8571f3d Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Sun, 26 Jul 2026 22:55:22 +0100 Subject: [PATCH 03/18] feat(store): define UserStore, SessionStore, AuditStore interfaces --- store/interfaces.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 store/interfaces.go diff --git a/store/interfaces.go b/store/interfaces.go new file mode 100644 index 0000000..0f412df --- /dev/null +++ b/store/interfaces.go @@ -0,0 +1,94 @@ +package store + +import ( + "context" + "time" +) + +// User is the domain representation of a user record. +// Storage implementations map their own row/document types to/from this. +type User struct { + ID string + Email string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time +} + +// UserStore defines persistence operations for users. +// v2 ships one implementation: store/postgres.PostgresUserStore. +type UserStore interface { + Create(ctx context.Context, user User) error + GetByEmail(ctx context.Context, email string) (User, error) + GetByID(ctx context.Context, id string) (User, error) + UpdateEmail(ctx context.Context, id string, newEmail string) error + UpdatePasswordHash(ctx context.Context, id string, newHash string) error + Delete(ctx context.Context, id string) error +} + +// Session is the domain representation of a refresh-token-backed session. +// TokenHash is the SHA-256 hash of the raw refresh token β€” the raw token +// is never persisted. +type Session struct { + ID string + FamilyID string + UserID string + TokenHash string + IP string + UserAgent string + CreatedAt time.Time + RevokedAt *time.Time +} + +// SessionStore defines persistence operations for sessions and refresh +// token rotation chains. v1 ships one implementation: +// store/postgres.PostgresSessionStore. +type SessionStore interface { + Create(ctx context.Context, s Session) error + GetByID(ctx context.Context, sessionID string) (Session, error) + GetByTokenHash(ctx context.Context, tokenHash string) (Session, error) + ListByUser(ctx context.Context, userID string) ([]Session, error) + Revoke(ctx context.Context, sessionID string) error + RevokeFamily(ctx context.Context, familyID string) error + RevokeAllForUser(ctx context.Context, userID string) error + + // RotateToken atomically revokes oldSessionID and creates newSession + // in a single storage operation (a DB transaction in the Postgres + // implementation). This prevents a crash between separate revoke + // and create calls from leaving a session family in an inconsistent + // state (old token dead, new token never created). + RotateToken(ctx context.Context, oldSessionID string, newSession Session) error +} + +// AuditEventType identifies the kind of audit event recorded. +type AuditEventType string + +const ( + EventSignupSuccess AuditEventType = "signup_success" + EventLoginSuccess AuditEventType = "login_success" + EventLoginFailed AuditEventType = "login_failed" + EventLogout AuditEventType = "logout" + EventLogoutAll AuditEventType = "logout_all" + EventTokenRotated AuditEventType = "token_rotated" + EventTokenReuseDetected AuditEventType = "token_reuse_detected" + EventSessionRevoked AuditEventType = "session_revoked" +) + +// AuditEvent is a single security-relevant, queryable record. +// Distinct from operational logging (see logger.Logger) β€” this is +// domain data written to the consuming app's own store. +type AuditEvent struct { + ID string + Type AuditEventType + UserID string + IP string + Metadata map[string]string + CreatedAt time.Time +} + +// AuditStore defines persistence for audit events. +// v1 ships one implementation: store/postgres.PostgresAuditStore. +type AuditStore interface { + Record(ctx context.Context, event AuditEvent) error + ListByUser(ctx context.Context, userID string, limit int) ([]AuditEvent, error) +} From db0fbe65914094fef7278a213984a2a518a774a7 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Sun, 26 Jul 2026 22:59:53 +0100 Subject: [PATCH 04/18] feat(store): define core interfaces and sentinel errors --- store/errors.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 store/errors.go diff --git a/store/errors.go b/store/errors.go new file mode 100644 index 0000000..d60f193 --- /dev/null +++ b/store/errors.go @@ -0,0 +1,14 @@ +package store + +import "errors" + +var ( + // ErrNotFound is returned by any store implementation when a + // lookup (by ID, email, or token hash) finds no matching record. + ErrNotFound = errors.New("store: record not found") + // ErrSessionNotOwned is returned when a caller-supplied userID does + // not match a session's actual owner. Shared here (rather than in + // auth or session) so neither package needs to depend on the other + // just to reference this error. + ErrSessionNotOwned = errors.New("store: session does not belong to this user") +) From b6801818301c16e6c6adee83c1f01c115273ab89 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Mon, 27 Jul 2026 11:45:57 +0100 Subject: [PATCH 05/18] feat: sentinel error and hasher interface defination and bcrypt implementation --- go.mod | 5 ++++ go.sum | 2 ++ security/errors.go | 7 ++++++ security/hasher.go | 43 +++++++++++++++++++++++++++++++++ store/interfaces.go | 58 ++++++++++++++++++++++----------------------- 5 files changed, 86 insertions(+), 29 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 security/errors.go create mode 100644 security/hasher.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d269fb --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/crydensync/cryden/v2 + +go 1.25.0 + +require golang.org/x/crypto v0.54.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d3f7642 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= diff --git a/security/errors.go b/security/errors.go new file mode 100644 index 0000000..b96eb90 --- /dev/null +++ b/security/errors.go @@ -0,0 +1,7 @@ +package security + +import "errors" + +var ( + ErrInvalidBcryptCost = errors.New("security: bcrypt cost out of valid range") +) diff --git a/security/hasher.go b/security/hasher.go new file mode 100644 index 0000000..715bb59 --- /dev/null +++ b/security/hasher.go @@ -0,0 +1,43 @@ +package security + +import "golang.org/x/crypto/bcrypt" + +// Hasher defines password hashing operations. v1 ships one implementation: +// BcryptHasher. Compare must run in constant time relative to a correct +// vs incorrect password β€” bcrypt's ComparePassword already guarantees this. +type Hasher interface { + Hash(password string) (string, error) + Compare(hash, password string) error +} + +// BcryptHasher is the v2 Hasher implementation. +type BcryptHasher struct { + // Cost is the bcrypt work factor. Must be set explicitly by the + // caller via Config β€” no silent default to a weak cost. + Cost int +} + +// NewBcryptHasher constructs a BcryptHasher. cost must be within +// bcrypt's valid range (bcrypt.MinCost..bcrypt.MaxCost); callers should +// use bcrypt.DefaultCost (10) as their own explicit choice, not rely on +// this constructor picking one for them. +func NewBcryptHasher(cost int) (*BcryptHasher, error) { + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + return nil, ErrInvalidBcryptCost + } + return &BcryptHasher{Cost: cost}, nil +} + +func (b *BcryptHasher) Hash(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), b.Cost) + if err != nil { + return "", nil + } + return string(bytes), nil +} + +func (b *BcryptHasher) Compare(hash, password string) error { + // bcrypt.CompareHashAndPassword is constant-time with respect to + // the password comparison itself. + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} diff --git a/store/interfaces.go b/store/interfaces.go index 0f412df..221954e 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -1,43 +1,43 @@ package store import ( - "context" - "time" + "context" + "time" ) // User is the domain representation of a user record. // Storage implementations map their own row/document types to/from this. type User struct { - ID string - Email string - PasswordHash string - CreatedAt time.Time - UpdatedAt time.Time + ID string + Email string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time } // UserStore defines persistence operations for users. // v2 ships one implementation: store/postgres.PostgresUserStore. type UserStore interface { - Create(ctx context.Context, user User) error - GetByEmail(ctx context.Context, email string) (User, error) - GetByID(ctx context.Context, id string) (User, error) - UpdateEmail(ctx context.Context, id string, newEmail string) error - UpdatePasswordHash(ctx context.Context, id string, newHash string) error - Delete(ctx context.Context, id string) error + Create(ctx context.Context, user User) error + GetByEmail(ctx context.Context, email string) (User, error) + GetByID(ctx context.Context, id string) (User, error) + UpdateEmail(ctx context.Context, id string, newEmail string) error + UpdatePasswordHash(ctx context.Context, id string, newHash string) error + Delete(ctx context.Context, id string) error } // Session is the domain representation of a refresh-token-backed session. // TokenHash is the SHA-256 hash of the raw refresh token β€” the raw token // is never persisted. type Session struct { - ID string - FamilyID string - UserID string - TokenHash string - IP string - UserAgent string - CreatedAt time.Time - RevokedAt *time.Time + ID string + FamilyID string + UserID string + TokenHash string + IP string + UserAgent string + CreatedAt time.Time + RevokedAt *time.Time } // SessionStore defines persistence operations for sessions and refresh @@ -64,14 +64,14 @@ type SessionStore interface { type AuditEventType string const ( - EventSignupSuccess AuditEventType = "signup_success" - EventLoginSuccess AuditEventType = "login_success" - EventLoginFailed AuditEventType = "login_failed" - EventLogout AuditEventType = "logout" - EventLogoutAll AuditEventType = "logout_all" - EventTokenRotated AuditEventType = "token_rotated" - EventTokenReuseDetected AuditEventType = "token_reuse_detected" - EventSessionRevoked AuditEventType = "session_revoked" + EventSignupSuccess AuditEventType = "signup_success" + EventLoginSuccess AuditEventType = "login_success" + EventLoginFailed AuditEventType = "login_failed" + EventLogout AuditEventType = "logout" + EventLogoutAll AuditEventType = "logout_all" + EventTokenRotated AuditEventType = "token_rotated" + EventTokenReuseDetected AuditEventType = "token_reuse_detected" + EventSessionRevoked AuditEventType = "session_revoked" ) // AuditEvent is a single security-relevant, queryable record. From 579c73b7b92893f0886fe7bc4118c2b5d7f51ae4 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Mon, 27 Jul 2026 11:58:40 +0100 Subject: [PATCH 06/18] test: salt test and cost validation --- security/hasher_test.go | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 security/hasher_test.go diff --git a/security/hasher_test.go b/security/hasher_test.go new file mode 100644 index 0000000..0caa648 --- /dev/null +++ b/security/hasher_test.go @@ -0,0 +1,46 @@ +package security + +import "testing" + +func TestBcryptHasher_HashAndCompare(t *testing.T) { + h, err := NewBcryptHasher(4) // low cost for fast tests + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hash, err := h.Hash("correct-password") + if err != nil { + t.Fatalf("hash failed: %v", err) + } + if hash == "correct-password" { + t.Fatal("hash must not equal the raw password") + } + + if err := h.Compare(hash, "correct-password"); err != nil { + t.Errorf("expected correct password to compare successfully, got: %v", err) + } + + if err := h.Compare(hash, "wrong-password"); err == nil { + t.Error("expected wrong password to fail comparison, got nil error") + } +} + +func TestNewBcryptHasher_RejectsInvalidCost(t *testing.T) { + if _, err := NewBcryptHasher(1); err == nil { + t.Error("expected error for cost below bcrypt.MinCost, got nil") + } + if _, err := NewBcryptHasher(100); err == nil { + t.Error("expected error for cost above bcrypt.MaxCost, got nil") + } +} + +func TestBcryptHasher_SameInputDifferentHashes(t *testing.T) { + // bcrypt salts automatically β€” hashing the same password twice + // must never produce the same hash. + h, _ := NewBcryptHasher(4) + h1, _ := h.Hash("same-password") + h2, _ := h.Hash("same-password") + if h1 == h2 { + t.Error("expected different hashes for the same password due to salting") + } +} From b279199ab31e5120800f27a193aa4349d8b61118 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Mon, 27 Jul 2026 13:35:08 +0100 Subject: [PATCH 07/18] feat: Google uuid ID generator defination and implementation --- go.mod | 5 ++++- go.sum | 2 ++ security/idgen.go | 26 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 security/idgen.go diff --git a/go.mod b/go.mod index 4d269fb..334cbab 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/crydensync/cryden/v2 go 1.25.0 -require golang.org/x/crypto v0.54.0 +require ( + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.54.0 +) diff --git a/go.sum b/go.sum index d3f7642..6307466 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= diff --git a/security/idgen.go b/security/idgen.go new file mode 100644 index 0000000..a9fe5b0 --- /dev/null +++ b/security/idgen.go @@ -0,0 +1,26 @@ +package security + +import "github.com/google/uuid" + +// IDGenerator defines ID generation for users, sessions, audit events, +// and session families. v2 ships one implementation: UUIDv7Generator. +// UUIDv7 is time-ordered (sortable, index-friendly) without leaking +// a predictable sequence the way time.Now().UnixNano() would. +type IDGenrator interface { + New() (string, error) +} + +// UUIDv7Generator is the v1 IDGenerator implementation. +type UUIDv7Generator struct{} + +func NewUUIDv7Generator() *UUIDv7Generator { + return &UUIDv7Generator{} +} + +func (g *UUIDv7Generator) New() (string, error) { + id, err := uuid.NewV7() + if err != nil { + return "", err + } + return id.String(), nil +} From 3a9d56ac794b79931869af2546b871f873fb3fa0 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Mon, 27 Jul 2026 13:38:08 +0100 Subject: [PATCH 08/18] test: add unit tests for UUIDv7 uniqueness and sortability --- security/idgen_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 security/idgen_test.go diff --git a/security/idgen_test.go b/security/idgen_test.go new file mode 100644 index 0000000..98bb572 --- /dev/null +++ b/security/idgen_test.go @@ -0,0 +1,30 @@ +package security + +import "testing" + +func TestUUIDv7Generator_ProducesUniqueIDs(t *testing.T) { + g := NewUUIDv7Generator() + seen := make(map[string]bool) + for i := 0; i < 1000; i++ { + id, err := g.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seen[id] { + t.Fatalf("duplicate ID generated: %s", id) + } + seen[id] = true + } +} + +func TestUUIDv7Generator_IDsAreRoughlySortable(t *testing.T) { + // UUIDv7 embeds a timestamp prefix, so IDs generated in sequence + // should sort lexicographically in the order they were created β€” + // the property that motivated choosing it over UUIDv4. + g := NewUUIDv7Generator() + first, _ := g.New() + second, _ := g.New() + if first >= second { + t.Errorf("expected sequential UUIDv7s to sort in creation order, got %s then %s", first, second) + } +} From accf0a188c228f390c79dce9b74f5f22ba3db722 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 10:08:25 +0100 Subject: [PATCH 09/18] feat: Rate limiter defination and implementation --- security/ratelimiter.go | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 security/ratelimiter.go diff --git a/security/ratelimiter.go b/security/ratelimiter.go new file mode 100644 index 0000000..8e0e310 --- /dev/null +++ b/security/ratelimiter.go @@ -0,0 +1,59 @@ +package security + +import ( + "context" + "sync" + "time" +) + +// RateLimiter defines a generic allow/deny check keyed by an opaque +// string. The limiter has no knowledge of what the key represents β€” +// callers decide (e.g. ip+":"+email for login attempts). This keeps +// the engine framework-agnostic: it never infers a caller's identity +// or IP itself, it only receives what's passed in. +type RateLimiter interface { + Allow(ctx context.Context, key string) (bool, error) +} + +// InMemoryRateLimiter is the v2 RateLimiter implementation: a simple +// fixed-window counter per key. Not distributed β€” fine for a single +// process; a Redis-backed implementation is a later addition, not v2. +type InMemoryRateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + counters map[string]*windowCounter +} + +type windowCounter struct { + count int + windowEnd time.Time +} + +// NewInMemoryRateLimiter constructs a limiter allowing `limit` calls +// per `window` duration, per key. Both must be set explicitly by the +// caller via Config β€” no hidden default limits. +func NewInMemoryRateLimiter(limit int, window time.Duration) *InMemoryRateLimiter { + return &InMemoryRateLimiter{ + limit: limit, + window: window, + counters: make(map[string]*windowCounter), + } +} + +func (r *InMemoryRateLimiter) Allow(ctx context.Context, key string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + + now := time.Now() + c, ok := r.counters[key] + if !ok || now.After(c.windowEnd) { + r.counters[key] = &windowCounter{count: 1, windowEnd: now.Add(r.window)} + return true, nil + } + if c.count >= r.limit { + return false, nil + } + c.count++ + return true, nil +} From 65a43c96b63fd396edf17cc84c68d9f4aec60572 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 10:10:59 +0100 Subject: [PATCH 10/18] test: Rate limiter unit test basic --- security/ratelimiter_test.go | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 security/ratelimiter_test.go diff --git a/security/ratelimiter_test.go b/security/ratelimiter_test.go new file mode 100644 index 0000000..2ede2c5 --- /dev/null +++ b/security/ratelimiter_test.go @@ -0,0 +1,63 @@ +package security + +import ( + "context" + "testing" + "time" +) + +func TestInMemoryRateLimiter_AllowsUpToLimit(t *testing.T) { + rl := NewInMemoryRateLimiter(3, time.Minute) + ctx := context.Background() + + for i := 0; i < 3; i++ { + allowed, err := rl.Allow(ctx, "key1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !allowed { + t.Fatalf("expected call %d to be allowed", i+1) + } + } + + allowed, err := rl.Allow(ctx, "key1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if allowed { + t.Error("expected 4th call within the window to be denied") + } +} + +func TestInMemoryRateLimiter_KeysAreIndependent(t *testing.T) { + rl := NewInMemoryRateLimiter(1, time.Minute) + ctx := context.Background() + + a1, _ := rl.Allow(ctx, "a") + b1, _ := rl.Allow(ctx, "b") + if !a1 || !b1 { + t.Fatal("expected first call for each independent key to be allowed") + } + + a2, _ := rl.Allow(ctx, "a") + if a2 { + t.Error("expected second call for key 'a' to be denied") + } +} + +func TestInMemoryRateLimiter_ResetsAfterWindow(t *testing.T) { + rl := NewInMemoryRateLimiter(1, 10*time.Millisecond) + ctx := context.Background() + + first, _ := rl.Allow(ctx, "key1") + if !first { + t.Fatal("expected first call to be allowed") + } + + time.Sleep(20 * time.Millisecond) + + afterWindow, _ := rl.Allow(ctx, "key1") + if !afterWindow { + t.Error("expected call after window expiry to be allowed again") + } +} From 30255b66494beffd00e1faa3d2f084835ceccd89 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 10:31:36 +0100 Subject: [PATCH 11/18] feat: token sentinel errors and token random generation --- token/errors.go | 9 +++++++++ token/generator.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 token/errors.go create mode 100644 token/generator.go diff --git a/token/errors.go b/token/errors.go new file mode 100644 index 0000000..ccd4a04 --- /dev/null +++ b/token/errors.go @@ -0,0 +1,9 @@ +package token + +import "errors" + +var ( + ErrTokenByteLengthTooShort = errors.New("token: byte length below minimum safe entropy (16 bytes / 128 bits)") + ErrMissingJWTSecret = errors.New("token: JWT secret must not be empty") + ErrInvalidTTL = errors.New("token: TTL must be greater than zero") +) diff --git a/token/generator.go b/token/generator.go new file mode 100644 index 0000000..fea8d11 --- /dev/null +++ b/token/generator.go @@ -0,0 +1,43 @@ +package token + +import ( + "crypto/rand" + "encoding/hex" +) + +// TokenGenerator defines generation of opaque, cryptographically random +// refresh tokens. v2 ships one implementation: CryptoRandTokenGenerator. +// The raw token returned here is what's given to the caller β€” it is +// never persisted as-is; the engine hashes it (SHA-256) before storing +// in SessionStore. See token/refresh.go. +type TokenGenerator interface { + New() (string, error) +} + +// CryptoRandTokenGenerator is the v2 TokenGenerator implementation. +// Generates 32 raw random bytes (256 bits) via crypto/rand and +// hex-encodes them for safe storage/transport as a string. +type CryptoRandTokenGenerator struct { + // ByteLength is the number of random bytes generated per token. + // 32 bytes (256 bits) is the standard baseline for opaque tokens. + ByteLength int +} + +// NewCryptoRandTokenGenerator constructs a generator. byteLength must +// be set explicitly by the caller via Config; 32 is the recommended +// value if the caller has no specific reason to deviate. +func NewCryptoRandTokenGenerator(byteLength int) (*CryptoRandTokenGenerator, error) { + if byteLength < 16 { + // Reject anything below 128 bits β€” too weak for a session token. + return nil, ErrTokenByteLengthTooShort + } + return &CryptoRandTokenGenerator{ByteLength: byteLength}, nil +} + +func (g *CryptoRandTokenGenerator) New() (string, error) { + buf := make([]byte, g.ByteLength) + if _, err := rand.Read(buf); err != nil { + return "", nil + } + return hex.EncodeToString(buf), nil +} From 122c0395e407c18649dd3ec865a19daaf6d7d3ee Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 14:54:58 +0100 Subject: [PATCH 12/18] feat: Access token implemetation and verification --- go.mod | 1 + go.sum | 2 ++ token/jwt.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 token/jwt.go diff --git a/go.mod b/go.mod index 334cbab..fe20a0b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/crydensync/cryden/v2 go 1.25.0 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.54.0 ) diff --git a/go.sum b/go.sum index 6307466..cbca2de 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= diff --git a/token/jwt.go b/token/jwt.go new file mode 100644 index 0000000..9acab08 --- /dev/null +++ b/token/jwt.go @@ -0,0 +1,74 @@ +package token + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidAccessToken = errors.New("token: access token invalid or expired") +) + +// JWTIssuer issues and verifies short-lived, stateless access tokens. +// Unlike refresh tokens, access tokens are never persisted or looked +// up in the DB β€” validity is proven entirely by signature + expiry. +// +// Secret and TTL must be set explicitly at construction β€” no default +// secret exists anywhere in this package (see NewJWTIssuer). +type JWTIssuer struct { + secret []byte + ttl time.Duration +} + +// NewJWTIssuer constructs a JWTIssuer. secret must be non-empty β€” the +// engine fails construction entirely if no secret is configured +// (see cryden.New / Config validation), so an empty secret reaching +// here would already be a bug upstream, not something to default past. +func NewJWTIssuer(secret string, ttl time.Duration) (*JWTIssuer, error) { + if secret == "" { + return nil, ErrMissingJWTSecret + } + if ttl <= 0 { + return nil, ErrInvalidTTL + } + return &JWTIssuer{secret: []byte(secret), ttl: ttl}, nil +} + +type accessClaims struct { + jwt.RegisteredClaims +} + +// Issue creates a signed access token for userID, expiring after the +// issuer's configured TTL. +func (j *JWTIssuer) Issue(userID string) (string, error) { + now := time.Now() + cliams := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)), + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, cliams) + return t.SignedString(j.secret) +} + +// Verify checks the token's signature and expiry, returning the +// embedded user ID if valid. +func (j *JWTIssuer) Verify(tokenStr string) (string, error) { + claims := &accessClaims{} + parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + // Reject any token not signed with the algorithm we issue β€” + // prevents algorithm-confusion attacks (e.g. "alg: none"). + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidAccessToken + } + return j.secret, nil + }) + if err != nil || !parsed.Valid { + return "", ErrInvalidAccessToken + } + return claims.Subject, nil +} From 5b25a5f0543ac270c7ac3769654bb2ebac16d101 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 15:08:04 +0100 Subject: [PATCH 13/18] feat: opaque token rotation and verification --- security/idgen.go | 2 +- token/refresh.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 token/refresh.go diff --git a/security/idgen.go b/security/idgen.go index a9fe5b0..026ec85 100644 --- a/security/idgen.go +++ b/security/idgen.go @@ -6,7 +6,7 @@ import "github.com/google/uuid" // and session families. v2 ships one implementation: UUIDv7Generator. // UUIDv7 is time-ordered (sortable, index-friendly) without leaking // a predictable sequence the way time.Now().UnixNano() would. -type IDGenrator interface { +type IDGenerator interface { New() (string, error) } diff --git a/token/refresh.go b/token/refresh.go new file mode 100644 index 0000000..dec2a9d --- /dev/null +++ b/token/refresh.go @@ -0,0 +1,99 @@ +package token + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/crydensync/cryden/v2/security" + "github.com/crydensync/cryden/v2/store" +) + +var ( + ErrInvalidToken = errors.New("token: refresh token not found or malformed") + // ErrTokenReused is returned when an already-rotated (revoked) refresh + // token is presented again β€” a signal of possible theft. The entire + // session family has already been revoked by the time this is returned. + ErrTokenReused = errors.New("token: refresh token reuse detected, session family revoked") +) + +// HashToken returns the SHA-256 hex digest of a raw token. Only this +// hash is ever persisted β€” the raw token exists solely in the value +// returned to the caller at issue/rotation time. +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// RefreshResult carries the newly issued raw refresh token and its +// backing session record. +type RefreshResult struct { + RawToken string + Session store.Session +} + +// Rotate validates a presented raw refresh token, detects reuse, and β€” +// if valid β€” issues a new token in the same session family while +// revoking the old one. +// +// Flow: +// 1. Hash the incoming raw token, look it up. +// 2. Not found -> ErrInvalidToken. +// 3. Found but already revoked -> reuse detected: revoke the entire +// family, return ErrTokenReused. Caller is responsible for +// recording the token_reuse_detected audit event β€” this function +// only enforces the security invariant, it does not log. +// 4. Found and valid -> revoke the old row, issue + persist a new +// token under the same family_id, return the new raw token. +func Rotate( + ctx context.Context, + sessions store.SessionStore, + gen TokenGenerator, + ids security.IDGenerator, + rawToken string, +) (RefreshResult, error) { + hash := HashToken(rawToken) + + existing, err := sessions.GetByTokenHash(ctx, hash) + if err != nil { + return RefreshResult{}, ErrInvalidToken + } + + if existing.RevokedAt != nil { + // Reuse of a token that was already rotated away β€” treat the + // whole family as compromised. Return existing (not a zero + // value) so the caller can attribute the audit event to the + // correct user/family β€” losing that context here would make + // the token_reuse_detected event useless for investigation. + if revokeErr := sessions.RevokeFamily(ctx, existing.FamilyID); revokeErr != nil { + return RefreshResult{Session: existing}, revokeErr + } + return RefreshResult{Session: existing}, ErrTokenReused + } + + newRaw, err := gen.New() + if err != nil { + return RefreshResult{}, err + } + + newID, err := ids.New() + if err != nil { + return RefreshResult{}, err + } + + newSession := store.Session{ + ID: newID, + FamilyID: existing.FamilyID, + UserID: existing.UserID, + TokenHash: HashToken(newRaw), + IP: existing.IP, + UserAgent: existing.UserAgent, + } + + if err := sessions.RotateToken(ctx, existing.ID, newSession); err != nil { + return RefreshResult{}, err + } + + return RefreshResult{RawToken: newRaw, Session: newSession}, nil +} From ccc8d1a6598337dedb8ca523f301eb8abd8b32e0 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 15:10:24 +0100 Subject: [PATCH 14/18] test: token generator and hash test --- token/generator_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 token/generator_test.go diff --git a/token/generator_test.go b/token/generator_test.go new file mode 100644 index 0000000..56592a0 --- /dev/null +++ b/token/generator_test.go @@ -0,0 +1,36 @@ +package token + +import "testing" + +func TestCryptoRandTokenGenerator_ProducesUniqueTokens(t *testing.T) { + g, err := NewCryptoRandTokenGenerator(32) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + seen := make(map[string]bool) + for i := 0; i < 1000; i++ { + tok, err := g.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seen[tok] { + t.Fatalf("duplicate token generated: %s", tok) + } + seen[tok] = true + } +} + +func TestNewCryptoRandTokenGenerator_RejectsTooShort(t *testing.T) { + if _, err := NewCryptoRandTokenGenerator(8); err == nil { + t.Error("expected error for byte length below 16, got nil") + } +} + +func TestHashToken_Deterministic(t *testing.T) { + if HashToken("same-input") != HashToken("same-input") { + t.Error("expected HashToken to be deterministic for the same input") + } + if HashToken("input-a") == HashToken("input-b") { + t.Error("expected different inputs to produce different hashes") + } +} From 77f8a35219cb571b3d4064216e3fff7e8103a318 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 15:12:35 +0100 Subject: [PATCH 15/18] feat: access token tests, verify and validate --- token/jwt_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 token/jwt_test.go diff --git a/token/jwt_test.go b/token/jwt_test.go new file mode 100644 index 0000000..c5af385 --- /dev/null +++ b/token/jwt_test.go @@ -0,0 +1,83 @@ +package token + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestJWTIssuer_IssueAndVerify(t *testing.T) { + iss, err := NewJWTIssuer("test-secret", time.Minute) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tok, err := iss.Issue("user-123") + if err != nil { + t.Fatalf("issue failed: %v", err) + } + + userID, err := iss.Verify(tok) + if err != nil { + t.Fatalf("verify failed: %v", err) + } + if userID != "user-123" { + t.Errorf("expected user-123, got %s", userID) + } +} + +func TestJWTIssuer_RejectsExpiredToken(t *testing.T) { + iss, _ := NewJWTIssuer("test-secret", 1*time.Millisecond) + tok, _ := iss.Issue("user-123") + + time.Sleep(10 * time.Millisecond) + + if _, err := iss.Verify(tok); err == nil { + t.Error("expected expired token to fail verification") + } +} + +func TestJWTIssuer_RejectsWrongSecret(t *testing.T) { + iss1, _ := NewJWTIssuer("secret-one", time.Minute) + iss2, _ := NewJWTIssuer("secret-two", time.Minute) + + tok, _ := iss1.Issue("user-123") + if _, err := iss2.Verify(tok); err == nil { + t.Error("expected token signed with a different secret to fail verification") + } +} + +func TestJWTIssuer_RejectsAlgNone(t *testing.T) { + // Algorithm-confusion attack: a token claiming alg "none" must be + // rejected outright, never accepted as if unsigned tokens were valid. + iss, _ := NewJWTIssuer("test-secret", time.Minute) + + claims := accessClaims{ + RegisteredClaims: jwt.RegisteredClaims{Subject: "attacker"}, + } + unsignedToken := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + tokStr, err := unsignedToken.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("failed to construct test token: %v", err) + } + + if _, err := iss.Verify(tokStr); err == nil { + t.Error("expected alg:none token to be rejected, got nil error") + } +} + +func TestNewJWTIssuer_RejectsEmptySecret(t *testing.T) { + if _, err := NewJWTIssuer("", time.Minute); err == nil { + t.Error("expected error for empty secret, got nil") + } +} + +func TestNewJWTIssuer_RejectsInvalidTTL(t *testing.T) { + if _, err := NewJWTIssuer("secret", 0); err == nil { + t.Error("expected error for zero TTL, got nil") + } + if _, err := NewJWTIssuer("secret", -time.Minute); err == nil { + t.Error("expected error for negative TTL, got nil") + } +} From 67305187ea65c6e6af00eee804e918a0446e4980 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 15:26:05 +0100 Subject: [PATCH 16/18] feat: add more store defination for more features in v2.1 --- store/interfaces.go | 77 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/store/interfaces.go b/store/interfaces.go index 221954e..54ee532 100644 --- a/store/interfaces.go +++ b/store/interfaces.go @@ -8,15 +8,16 @@ import ( // User is the domain representation of a user record. // Storage implementations map their own row/document types to/from this. type User struct { - ID string - Email string - PasswordHash string - CreatedAt time.Time - UpdatedAt time.Time + ID string + Email string + PasswordHash string + FailedAttempts int + LockedUntil *time.Time + CreatedAt time.Time + UpdatedAt time.Time } // UserStore defines persistence operations for users. -// v2 ships one implementation: store/postgres.PostgresUserStore. type UserStore interface { Create(ctx context.Context, user User) error GetByEmail(ctx context.Context, email string) (User, error) @@ -24,6 +25,17 @@ type UserStore interface { UpdateEmail(ctx context.Context, id string, newEmail string) error UpdatePasswordHash(ctx context.Context, id string, newHash string) error Delete(ctx context.Context, id string) error + + // IncrementFailedAttempts records one failed login and returns the + // new total count, so callers can decide whether to lock the + // account without a separate read. + IncrementFailedAttempts(ctx context.Context, id string) (int, error) + // ResetFailedAttempts clears the counter β€” called on successful login. + ResetFailedAttempts(ctx context.Context, id string) error + // LockAccount sets LockedUntil. Persistent (DB-backed), not + // in-memory β€” must survive process restarts and work correctly + // across multiple instances, unlike the rate limiter. + LockAccount(ctx context.Context, id string, until time.Time) error } // Session is the domain representation of a refresh-token-backed session. @@ -64,14 +76,19 @@ type SessionStore interface { type AuditEventType string const ( - EventSignupSuccess AuditEventType = "signup_success" - EventLoginSuccess AuditEventType = "login_success" - EventLoginFailed AuditEventType = "login_failed" - EventLogout AuditEventType = "logout" - EventLogoutAll AuditEventType = "logout_all" - EventTokenRotated AuditEventType = "token_rotated" - EventTokenReuseDetected AuditEventType = "token_reuse_detected" - EventSessionRevoked AuditEventType = "session_revoked" + EventSignupSuccess AuditEventType = "signup_success" + EventLoginSuccess AuditEventType = "login_success" + EventLoginFailed AuditEventType = "login_failed" + EventLogout AuditEventType = "logout" + EventLogoutAll AuditEventType = "logout_all" + EventTokenRotated AuditEventType = "token_rotated" + EventTokenReuseDetected AuditEventType = "token_reuse_detected" + EventSessionRevoked AuditEventType = "session_revoked" + EventAccountLocked AuditEventType = "account_locked" + EventPasswordChanged AuditEventType = "password_changed" + EventEmailChangeRequested AuditEventType = "email_change_requested" + EventEmailChanged AuditEventType = "email_changed" + EventAccountDeleted AuditEventType = "account_deleted" ) // AuditEvent is a single security-relevant, queryable record. @@ -92,3 +109,35 @@ type AuditStore interface { Record(ctx context.Context, event AuditEvent) error ListByUser(ctx context.Context, userID string, limit int) ([]AuditEvent, error) } + +// VerificationPurpose distinguishes what a verification token is for β€” +// a single table/store serves both signup email verification and +// email-change confirmation, since the lifecycle (issue, hash, expire, +// consume once) is identical. +type VerificationPurpose string + +const ( + PurposeEmailVerify VerificationPurpose = "email_verify" + PurposeEmailChange VerificationPurpose = "email_change" +) + +// VerificationToken represents a single-use, expiring token sent to an +// email address. NewEmail is only set for PurposeEmailChange β€” it's +// the address the user is trying to change TO, not their current one. +type VerificationToken struct { + ID string + UserID string + Purpose VerificationPurpose + TokenHash string + NewEmail string // only used for PurposeEmailChange + ExpiresAt time.Time + UsedAt *time.Time + CreatedAt time.Time +} + +// VerificationStore defines persistence for verification tokens. +type VerificationStore interface { + Create(ctx context.Context, vt VerificationToken) error + GetByTokenHash(ctx context.Context, tokenHash string) (VerificationToken, error) + MarkUsed(ctx context.Context, id string) error +} From 9b46146328bb712c4ac5505f1d2ae5701db6b48d Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 16:55:02 +0100 Subject: [PATCH 17/18] feat: operational logging, engine-internal for dev debug: warn, debug, info and error --- token/logger/console.go | 59 +++++++++++++++++++++++++++++++++++++++++ token/logger/logger.go | 17 ++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 token/logger/console.go create mode 100644 token/logger/logger.go diff --git a/token/logger/console.go b/token/logger/console.go new file mode 100644 index 0000000..85174b2 --- /dev/null +++ b/token/logger/console.go @@ -0,0 +1,59 @@ +package logger + +import ( + "encoding/json" + "os" + "time" +) + +// ConsoleJSONLogger is the v2 Logger implementation. It writes one +// JSON object per line to stdout β€” the standard 12-factor pattern, +// letting the consuming app's own infra (Docker, systemd, a log +// agent/sidecar) route output to file/cloud as needed. This package +// never writes to disk or calls a cloud logging API directly. +type ConsoleJSONLogger struct{} + +func NewConsoleJSONLogger() *ConsoleJSONLogger { + return &ConsoleJSONLogger{} +} + +type logLine struct { + Level string `json:"level"` + Message string `json:"message"` + Fields map[string]string `json:"fields,omitempty"` + Timestamp string `json:"timestamp"` +} + +func (l *ConsoleJSONLogger) write(level, msg string, fields map[string]string) { + line := logLine{ + Level: level, + Message: msg, + Fields: fields, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + b, err := json.Marshal(line) + if err != nil { + // Marshaling a small struct of strings should never fail; if it + // somehow does, fall back to a plain-text line rather than + // silently dropping the log. + os.Stdout.WriteString(level + ": " + msg + "\n") + return + } + os.Stdout.Write(append(b, '\n')) +} + +func (l *ConsoleJSONLogger) Debug(msg string, fields map[string]string) { + l.write("debug", msg, fields) +} + +func (l *ConsoleJSONLogger) Info(msg string, fields map[string]string) { + l.write("info", msg, fields) +} + +func (l *ConsoleJSONLogger) Warn(msg string, fields map[string]string) { + l.write("warn", msg, fields) +} + +func (l *ConsoleJSONLogger) Error(msg string, fields map[string]string) { + l.write("error", msg, fields) +} diff --git a/token/logger/logger.go b/token/logger/logger.go new file mode 100644 index 0000000..47c9434 --- /dev/null +++ b/token/logger/logger.go @@ -0,0 +1,17 @@ +package logger + +// Logger defines operational logging β€” engine-internal debug/info/ +// error messages for developers debugging their own deployment. This +// is distinct from store.AuditStore, which records queryable, +// security-relevant domain events (login, logout, token reuse, etc.). +// +// v2 ships one implementation: ConsoleJSONLogger. The consuming app +// wires whatever Logger it wants (file, cloud, console) β€” the engine +// never ships logs anywhere on its own. See project note on the +// zero-telemetry principle. +type Logger interface { + Debug(msg string, fields map[string]string) + Info(msg string, fields map[string]string) + Warn(msg string, fields map[string]string) + Error(msg string, fields map[string]string) +} From 65bba7c75aa5c287789bd3e8a08b8a2b58fbefa9 Mon Sep 17 00:00:00 2001 From: raymondproguy02 Date: Tue, 28 Jul 2026 16:57:35 +0100 Subject: [PATCH 18/18] feat: operational logging, engine-internal for dev debug: warn, debug, info and error --- {token/logger => logger}/console.go | 0 {token/logger => logger}/logger.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {token/logger => logger}/console.go (100%) rename {token/logger => logger}/logger.go (100%) diff --git a/token/logger/console.go b/logger/console.go similarity index 100% rename from token/logger/console.go rename to logger/console.go diff --git a/token/logger/logger.go b/logger/logger.go similarity index 100% rename from token/logger/logger.go rename to logger/logger.go