coopdevs / coopdevs/timeoverflow
Replace Devise with Rails built-in authentication
- Dominant language
- Ruby
- Stars
- 170
- Forks
- 75
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
Part of https://github.com/coopdevs/timeoverflow/issues/835 (supply chain risk reduction). Devise was added to this project in 2013, at a time when Rails lacked most of the authentication primitives needed to build a secure login system. Rails 7.2 has since closed that gap almost entirely. This issue proposes migrating away from Devise and relying exclusively on Rails built-ins, which would reduce the dependency surface, make the auth flow easier to reason about, and remove a large category of magic from the codebase.
## What Devise currently provides
The `User` model uses seven Devise modules:
| Module | What it does |
|---|---|
| `database_authenticatable` | bcrypt password hashing, sign-in/sign-out |
| `recoverable` | password-reset tokens, email |
| `rememberable` | "remember me" cookie |
| `confirmable` | email confirmation on signup / on email change |
| `lockable` | locks account after N failed attempts, unlock via email |
| `trackable` | records `sign_in_count`, `current/last_sign_in_at/ip` |
| `timeoutable` | auto-logout after 1 h of inactivity |
Routes use `devise_for :users` with a custom `SessionsController < Devise::SessionsController` that only suppresses flash messages. All other controllers use Devise helpers (`current_user`, `authenticate_user!`, `user_signed_in?`).
## What Rails 7.2 already provides natively
- **`has_secure_password`** — bcrypt hashing, `authenticate`, `password`/`password_confirmation` writers, `password_reset_token` (Rails 7.1+), `generates_token_for(:password_reset, expires_in: 6.hours)` — covers `database_authenticatable` and `recoverable` completely.
- **`generates_token_for`** (Rails 7.1) — signed, expiring tokens suitable for confirmation and unlock links, replacing `confirmable` and `lockable` token generation.
- **`config.force_ssl` + `config.session_store`** — session security handled by the framework.
- `ActiveSupport::SecureRandom`, `MessageVerifier` — token signing without a gem.
- The authentication generator added in Rails 8 (`rails generate authentication`) is a useful reference implementation even if not used directly.
The `encrypted_password` column in the `users` table can be renamed to `password_digest` (the column `has_secure_password` expects) in a single migration with no data loss.
## Proposed migration plan
### Phase 1 — Password authentication (`database_authenticatable`)
1. Add `has_secure_password` to `User`.
2. Rename `encrypted_password` → `password_digest` (or configure `has_secure_password :digest_column`).
3. Replace `Devise::SessionsController` with a plain `SessionsController` that calls `User.authenticate_by(email:, password:)` (Rails 7.1 method, constant-time, case-insensitive).
4. Implement `current_user` and `authenticate_user!` in `ApplicationController` (a `before_action` that checks `session[:user_id]`).
5. Remove `devise_for` from routes and replace with plain resource routes for sessions.
### Phase 2 — Password reset (`recoverable`)
1. Use `User.generates_token_for(:password_reset, expires_in: 6.hours) { password_salt.last(10) }` — token is automatically invalidated once the password changes.
2. Implement `PasswordResetsController` with `new`, `create`, `edit`, `update` actions.
3. Send reset email via an existing ActionMailer or a new `UserMailer`.
### Phase 3 — Email confirmation (`confirmable`)
1. Use `generates_token_for(:email_confirmation, expires_in: 2.days) { email }` — token is automatically invalidated once the email is confirmed.
2. Implement `ConfirmationsController` with `create` (resend) and `update` (confirm) actions.
3. Retain the `confirmed_at` and `unconfirmed_email` columns; remove Devise-managed columns (`confirmation_token`, `confirmation_sent_at`).
### Phase 4 — Account locking (`lockable`)
1. Retain `failed_attempts` and `locked_at` columns.
2. Implement incrementing `failed_attempts` in the sessions `create` action (reset on success, lock after 5).
3. Use `generates_token_for(:unlock, expires_in: 1.day)` for the unlock email link.
4. Implement `UnlocksController`.
### Phase 5 — Remember me (`rememberable`)
1. Retain `remember_created_at` column (useful for auditing) or drop if not needed.
2. Use a signed, permanent cookie with `cookies.signed.permanent[:user_id]` in the sessions controller; clear on sign out.
### Phase 6 — Session timeout (`timeoutable`)
1. Store `session[:last_active_at]` on every request.
2. Add a `before_action :check_session_timeout!` in `ApplicationController` that signs out and redirects if more than 1 hour has elapsed.
### Phase 7 — Tracking (`trackable`)
1. Retain all tracking columns (`sign_in_count`, `current_sign_in_at`, `last_sign_in_at`, `current_sign_in_ip`, `last_sign_in_ip`).
2. Update them in the sessions `create` action (three lines of code).
### Phase 8 — Cleanup
1. Remove `devise` and `devise-i18n` from the `Gemfile`.
2. Delete `config/initializers/devise.rb`.
3. Remove `app/views/devise/` and replace with plain views under `app/views/sessions/`, `app/views/password_resets/`, etc.
4. Update i18n keys from `devise.*` to custom keys.
5. Update Active Admin config (`config.logout_link_path`, `config.current_user_method`) to use the new routes/helpers — these are already using the standard `current_user` helper so minimal change is needed.
6. Drop obsolete Devise-specific columns if unused (e.g. `unlock_token` can be replaced by the generated token).
## What this is NOT
- This is not a proposal to remove email confirmation, account locking, or any current security feature.
- This is not a big-bang rewrite; each phase can be a standalone PR with its own tests.
## Notes
- The existing `password_digest` column (from `has_secure_password`) was dropped in migration `20180525141138` in favour of Devise's `encrypted_password`. The rename migration reverses that decision with no data loss.
- The dummy-email logic (`user{id}@example.com` for users created without email) is unrelated to Devise and can stay as-is.
- `devise-i18n` translations will need to be ported; most strings already have equivalents in the existing locale files.
Happy to open a draft PR for Phase 1 as a starting point for discussion.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.