Skip to content

Commit aec9e78

Browse files
committed
create signup code (including email verification), as well as some login models such as refresh tokens (but no implementation created)
mailers also introduced as well
1 parent 3aeafcf commit aec9e78

23 files changed

Lines changed: 471 additions & 1 deletion

backend/Gemfile.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ GEM
7474
uri (>= 0.13.1)
7575
ast (2.4.3)
7676
base64 (0.3.0)
77+
bcrypt (3.1.20)
7778
bcrypt_pbkdf (1.1.1)
7879
benchmark (0.5.0)
7980
bigdecimal (3.3.1)
@@ -109,6 +110,8 @@ GEM
109110
rdoc (>= 4.0.0)
110111
reline (>= 0.4.2)
111112
json (2.15.2)
113+
jwt (3.0.0)
114+
base64
112115
kamal (2.8.2)
113116
activesupport (>= 7.0)
114117
base64 (~> 0.2)
@@ -161,6 +164,8 @@ GEM
161164
racc (~> 1.4)
162165
nokogiri (1.18.10-arm64-darwin)
163166
racc (~> 1.4)
167+
nokogiri (1.18.10-x64-mingw-ucrt)
168+
racc (~> 1.4)
164169
nokogiri (1.18.10-x86_64-linux-gnu)
165170
racc (~> 1.4)
166171
nokogiri (1.18.10-x86_64-linux-musl)
@@ -174,6 +179,7 @@ GEM
174179
pg (1.6.2-aarch64-linux)
175180
pg (1.6.2-aarch64-linux-musl)
176181
pg (1.6.2-arm64-darwin)
182+
pg (1.6.2-x64-mingw-ucrt)
177183
pg (1.6.2-x86_64-linux)
178184
pg (1.6.2-x86_64-linux-musl)
179185
pp (0.6.3)
@@ -300,6 +306,8 @@ GEM
300306
tsort (0.2.0)
301307
tzinfo (2.0.6)
302308
concurrent-ruby (~> 1.0)
309+
tzinfo-data (1.2025.2)
310+
tzinfo (>= 1.0.0)
303311
unicode-display_width (3.2.0)
304312
unicode-emoji (~> 4.1)
305313
unicode-emoji (4.1.0)
@@ -318,14 +326,17 @@ PLATFORMS
318326
arm-linux-gnu
319327
arm-linux-musl
320328
arm64-darwin-24
329+
x64-mingw-ucrt
321330
x86_64-linux
322331
x86_64-linux-gnu
323332
x86_64-linux-musl
324333

325334
DEPENDENCIES
335+
bcrypt (~> 3.1.7)
326336
bootsnap
327337
brakeman
328338
debug
339+
jwt
329340
kamal
330341
pg (~> 1.1)
331342
puma (>= 5.0)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
module Api
2+
module V1
3+
class AuthController < ApplicationController
4+
def signup
5+
user = User.new(signup_params)
6+
7+
unless user.valid?
8+
return render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
9+
end
10+
11+
payload = user.attributes.slice(
12+
"first_name",
13+
"last_name",
14+
"preferred_name",
15+
"username",
16+
"email",
17+
"password_digest"
18+
)
19+
20+
EmailVerification.issue_for(user.email, payload)
21+
22+
render json: {
23+
message: "Verification code sent"
24+
}, status: :created
25+
end
26+
27+
def resend_email_verification
28+
email = params[:email].to_s
29+
30+
if email.blank?
31+
return render json: { error: "Email is required" }, status: :unprocessable_entity
32+
end
33+
34+
unless email.match?(URI::MailTo::EMAIL_REGEXP)
35+
return render json: { error: "Invalid email format" }, status: :unprocessable_entity
36+
end
37+
38+
if User.exists?(email: email.strip.downcase)
39+
return render json: { error: "Account already verified" }, status: :unprocessable_entity
40+
end
41+
42+
pending = EmailVerification.latest_for(email)
43+
44+
if pending.nil?
45+
return render json: { error: "No pending signup" }, status: :not_found
46+
end
47+
48+
EmailVerification.issue_for(email, pending.payload)
49+
render json: { message: "Verification code sent" }, status: :ok
50+
end
51+
52+
def verify_email
53+
email = params[:email].to_s
54+
code = params[:email_code].to_s
55+
56+
if email.blank? || code.blank?
57+
return render json: { error: "Email and email_code are required" }, status: :unprocessable_entity
58+
end
59+
60+
unless email.match?(URI::MailTo::EMAIL_REGEXP)
61+
return render json: { error: "Invalid email format" }, status: :unprocessable_entity
62+
end
63+
64+
pending = EmailVerification.active.find_by(email: email.strip.downcase)
65+
66+
if pending.nil?
67+
return render json: { error: "Invalid or expired code" }, status: :unprocessable_entity
68+
end
69+
70+
unless EmailVerification.consume!(email, code)
71+
return render json: { error: "Invalid or expired code" }, status: :unprocessable_entity
72+
end
73+
74+
user = User.new(pending.payload)
75+
76+
if user.save
77+
render json: { user: user_payload(user), message: "Account created" }, status: :created
78+
else
79+
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
80+
end
81+
end
82+
83+
private
84+
85+
def signup_params
86+
params.fetch(:user, params).permit(
87+
:first_name,
88+
:last_name,
89+
:preferred_name,
90+
:username,
91+
:email,
92+
:password,
93+
:password_confirmation
94+
)
95+
end
96+
97+
def user_payload(user)
98+
{
99+
id: user.id,
100+
first_name: user.first_name,
101+
last_name: user.last_name,
102+
preferred_name: user.preferred_name,
103+
username: user.username,
104+
email: user.email
105+
}
106+
end
107+
end
108+
end
109+
end
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
class ApplicationMailer < ActionMailer::Base
2-
default from: "from@example.com"
2+
DEFAULT_FROM_EMAIL = "auth@youthacks.org"
3+
4+
default from: ENV.fetch("FROM_EMAIL", DEFAULT_FROM_EMAIL)
35
layout "mailer"
46
end
7+

backend/app/mailers/user_mailer.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
class UserMailer < ApplicationMailer
2+
# Subject can be set in your I18n file at config/locales/en.yml
3+
# with the following lookup:
4+
#
5+
# en.user_mailer.verification_code.subject
6+
#
7+
def verification_code
8+
@code = params[:code]
9+
@expires_in = params[:expires_in] || 15 # default to 15 minutes
10+
mail(to: params[:email], subject: "Your verification code")
11+
end
12+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
require "digest"
2+
3+
class EmailVerification < ApplicationRecord
4+
TTL = 10.minutes
5+
6+
validates :email, :code_digest, :expires_at, :payload, presence: true
7+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
8+
9+
scope :active, -> { where(used_at: nil).where(arel_table[:expires_at].gt(Time.current)) }
10+
11+
def self.issue_for(email, payload)
12+
normalized = normalize_email(email)
13+
raw_code = format("%06d", SecureRandom.random_number(1_000_000))
14+
record = nil
15+
16+
transaction do
17+
where(email: normalized).delete_all
18+
record = create!(
19+
email: normalized,
20+
code_digest: digest(raw_code),
21+
expires_at: TTL.from_now,
22+
payload: payload
23+
)
24+
end
25+
26+
UserMailer.with(email: normalized, code: raw_code, expires_in: (TTL / 60)).verification_code.deliver_later
27+
record
28+
end
29+
30+
def self.latest_for(email)
31+
normalized = normalize_email(email)
32+
where(email: normalized).order(created_at: :desc).first
33+
end
34+
35+
def self.consume!(email, code)
36+
normalized = normalize_email(email)
37+
record = active.find_by(email: normalized)
38+
return false if record.nil?
39+
40+
unless ActiveSupport::SecurityUtils.secure_compare(record.code_digest, digest(code))
41+
return false
42+
end
43+
44+
record.update!(used_at: Time.current)
45+
true
46+
end
47+
48+
def self.digest(code)
49+
Digest::SHA256.hexdigest(code.to_s)
50+
end
51+
52+
def self.normalize_email(email)
53+
email.to_s.strip.downcase
54+
end
55+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
require "digest"
2+
3+
class RefreshToken < ApplicationRecord
4+
TTL = 30.days
5+
6+
belongs_to :user
7+
8+
validates :token_digest, presence: true, uniqueness: true
9+
validates :expires_at, presence: true
10+
11+
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
12+
13+
def self.issue_for(user, request)
14+
raw_token = SecureRandom.hex(32)
15+
create!(
16+
user: user,
17+
token_digest: digest(raw_token),
18+
expires_at: TTL.from_now,
19+
user_agent: request.user_agent.to_s,
20+
ip_address: request.remote_ip.to_s
21+
)
22+
raw_token
23+
end
24+
25+
def self.digest(raw_token)
26+
Digest::SHA256.hexdigest(raw_token.to_s)
27+
end
28+
29+
def revoke!
30+
update!(revoked_at: Time.current)
31+
end
32+
33+
def expired?
34+
expires_at <= Time.current
35+
end
36+
end

backend/app/models/user.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
class User < ApplicationRecord
2+
has_secure_password
3+
4+
has_many :refresh_tokens, dependent: :delete_all
5+
6+
validates :first_name, :last_name, :username, :email, presence: true
7+
validates :email, :username, uniqueness: true
8+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
9+
10+
before_validation :normalize_identity
11+
12+
def display_name
13+
preferred_name.presence || first_name
14+
end
15+
16+
def locked?
17+
locked_at.present?
18+
end
19+
20+
private
21+
22+
def normalize_identity
23+
self.email = email.to_s.strip.downcase.presence
24+
self.username = username.to_s.strip.downcase.presence
25+
end
26+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<p>Your verification code is:</p>
2+
<p><strong><%= @code %></strong></p>
3+
<p>This code expires in <%= @expires_in %> minutes.</p>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Your verification code is: <%= @code %>
2+
3+
This code expires in <%= @expires_in %> minutes.

backend/config/routes.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
# Can be used by load balancers and uptime monitors to verify that the app is live.
66
get "up" => "rails/health#show", as: :rails_health_check
77

8+
namespace :api do
9+
namespace :v1 do
10+
post "auth/signup", to: "auth#signup"
11+
post "auth/resend_email_verification", to: "auth#resend_email_verification"
12+
post "auth/verify_email", to: "auth#verify_email"
13+
end
14+
end
15+
816
# Defines the root path route ("/")
917
# root "posts#index"
1018
end

0 commit comments

Comments
 (0)