-
Notifications
You must be signed in to change notification settings - Fork 1
이메일 검증 로직 구현 #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
이메일 검증 로직 구현 #83
Changes from all commits
eb07f28
c8eefab
065239a
5f86ea1
8c13f84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| ALTER TABLE subscribers | ||
| ADD COLUMN IF NOT EXISTS verification_code VARCHAR(255), | ||
| ADD COLUMN IF NOT EXISTS verification_code_created_at TIMESTAMP WITH TIME ZONE, | ||
| ADD COLUMN IF NOT EXISTS verification_code_expires_at TIMESTAMP WITH TIME ZONE, | ||
| ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE; | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_subscribers_verification_expires | ||
| ON subscribers(verification_code_expires_at); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| use chrono::{DateTime, Duration, Utc}; | ||
| use rand::{Rng, rngs::ThreadRng}; | ||
| use secrecy::{ExposeSecret, Secret}; | ||
| use serde::{Deserialize, Serialize}; | ||
| use sqlx::{PgPool, postgres::PgQueryResult}; | ||
|
|
||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct EmailVerification { | ||
| pub email: String, | ||
| #[serde(skip_serializing)] | ||
| pub code: Secret<String>, | ||
| pub created_at: DateTime<Utc>, | ||
| pub expires_at: DateTime<Utc>, | ||
| pub verified: bool, | ||
| } | ||
|
|
||
| impl EmailVerification { | ||
| pub fn new(email: String) -> Self { | ||
| let code = generate_verification_code(); | ||
| let created_at = Utc::now(); | ||
| let expires_at = created_at + Duration::minutes(10); // 10분 후 만료 | ||
|
|
||
| Self { | ||
| email, | ||
| code, | ||
| created_at, | ||
| expires_at, | ||
| verified: false, | ||
| } | ||
| } | ||
|
|
||
| pub async fn save(&self, pool: &PgPool) -> Result<(), sqlx::Error> { | ||
| sqlx::query!( | ||
| r#" | ||
| UPDATE subscribers | ||
| SET verification_code = $1, | ||
| verification_code_created_at = $2, | ||
| verification_code_expires_at = $3, | ||
| is_verified = $4 | ||
| WHERE email = $5 | ||
| "#, | ||
| self.code.expose_secret(), | ||
| self.created_at, | ||
| self.expires_at, | ||
| self.verified, | ||
| self.email | ||
| ) | ||
| .execute(pool) | ||
| .await?; | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn verify(&mut self, code: &str, pool: &PgPool) -> Result<bool, sqlx::Error> { | ||
| if self.verified || Utc::now() > self.expires_at { | ||
| return Ok(false); | ||
| } | ||
|
|
||
| if self.code.expose_secret() == code { | ||
| self.verified = true; | ||
| sqlx::query!( | ||
| r#" | ||
| UPDATE subscribers | ||
| SET is_verified = true | ||
| WHERE email = $1 | ||
| "#, | ||
| self.email | ||
| ) | ||
| .execute(pool) | ||
| .await?; | ||
| Ok(true) | ||
| } else { | ||
| Ok(false) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn generate_verification_code() -> Secret<String> { | ||
| let mut rng = ThreadRng::default(); | ||
| let code: String = (0..6) | ||
| .map(|_| rng.random_range(0..10).to_string()) | ||
| .collect(); | ||
| Secret::new(code) | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| pub struct EmailVerificationStore { | ||
| pool: PgPool, | ||
| } | ||
|
|
||
| impl EmailVerificationStore { | ||
| pub fn new(pool: PgPool) -> Self { | ||
| Self { pool } | ||
| } | ||
|
|
||
| pub async fn create_verification( | ||
| &self, | ||
| email: String, | ||
| ) -> Result<EmailVerification, sqlx::Error> { | ||
| let verification = EmailVerification::new(email); | ||
| verification.save(&self.pool).await?; | ||
| Ok(verification) | ||
| } | ||
|
|
||
| pub async fn get_verification( | ||
| &self, | ||
| email: &str, | ||
| ) -> Result<Option<EmailVerification>, sqlx::Error> { | ||
| #[derive(sqlx::FromRow)] | ||
| struct VerificationRecord { | ||
| email: String, | ||
| verification_code: Option<String>, | ||
| verification_code_created_at: Option<DateTime<Utc>>, | ||
| verification_code_expires_at: Option<DateTime<Utc>>, | ||
| is_verified: bool, | ||
| } | ||
|
|
||
| let record = sqlx::query_as::<_, VerificationRecord>( | ||
| r#" | ||
| SELECT email, | ||
| verification_code, | ||
| verification_code_created_at, | ||
| verification_code_expires_at, | ||
| is_verified | ||
| FROM subscribers | ||
| WHERE email = $1 | ||
| "#, | ||
| ) | ||
| .bind(email) | ||
| .fetch_optional(&self.pool) | ||
| .await?; | ||
|
|
||
| Ok(record.map(|r| EmailVerification { | ||
| email: r.email, | ||
| code: Secret::new(r.verification_code.unwrap_or_default()), | ||
| created_at: r.verification_code_created_at.unwrap_or_else(Utc::now), | ||
| expires_at: r.verification_code_expires_at.unwrap_or_else(Utc::now), | ||
| verified: r.is_verified, | ||
| })) | ||
| } | ||
|
|
||
| pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, sqlx::Error> { | ||
| if let Some(mut verification) = self.get_verification(email).await? { | ||
| verification.verify(code, &self.pool).await | ||
| } else { | ||
| Ok(false) | ||
| } | ||
| } | ||
|
|
||
| pub async fn is_verified(&self, email: &str) -> Result<bool, sqlx::Error> { | ||
| let record = sqlx::query!( | ||
| r#" | ||
| SELECT is_verified | ||
| FROM subscribers | ||
| WHERE email = $1 | ||
| "#, | ||
| ) | ||
| .fetch_optional(&self.pool) | ||
| .await?; | ||
|
|
||
| Ok(record.map(|r| r.is_verified).unwrap_or(false)) | ||
| } | ||
|
|
||
| pub async fn cleanup_expired(&self) -> Result<PgQueryResult, sqlx::Error> { | ||
| sqlx::query!( | ||
| r#" | ||
| UPDATE subscribers | ||
| SET verification_code = NULL, | ||
| verification_code_created_at = NULL, | ||
| verification_code_expires_at = NULL | ||
| WHERE verification_code_expires_at < CURRENT_TIMESTAMP | ||
| "# | ||
| ) | ||
| .execute(&self.pool) | ||
| .await | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| use crate::domain::{SubscriberEmail, email_verification::EmailVerificationStore}; | ||
| use crate::email_client::EmailClient; | ||
| use secrecy::ExposeSecret; | ||
| use sqlx::PgPool; | ||
|
|
||
| pub struct EmailVerificationService { | ||
| store: EmailVerificationStore, | ||
| email_client: EmailClient, | ||
| } | ||
|
|
||
| impl EmailVerificationService { | ||
| pub fn new(pool: PgPool, email_client: EmailClient) -> Self { | ||
| Self { | ||
| store: EmailVerificationStore::new(pool), | ||
| email_client, | ||
| } | ||
| } | ||
|
|
||
| pub async fn send_verification_email(&self, email: String) -> Result<(), String> { | ||
| let verification = self | ||
| .store | ||
| .create_verification(email.clone()) | ||
| .await | ||
| .map_err(|e| format!("Failed to create verification: {}", e))?; | ||
|
|
||
| let subscriber_email = | ||
| SubscriberEmail::new(email.clone()).map_err(|_| "Invalid email format".to_string())?; | ||
|
|
||
| let subject = "이메일 인증 코드"; | ||
| let html_content = format!( | ||
| r#" | ||
| <h1>이메일 인증</h1> | ||
| <p>귀하의 이메일 인증 코드는 다음과 같습니다:</p> | ||
| <h2>{}</h2> | ||
| <p>이 코드는 10분 후에 만료됩니다.</p> | ||
| "#, | ||
| verification.code.expose_secret() | ||
| ); | ||
| let text_content = format!( | ||
| "귀하의 이메일 인증 코드는 {} 입니다. 이 코드는 10분 후에 만료됩니다.", | ||
| verification.code.expose_secret() | ||
| ); | ||
|
|
||
| self.email_client | ||
| .send_email( | ||
| subscriber_email, | ||
| subject.to_string(), | ||
| html_content, | ||
| text_content, | ||
| ) | ||
| .await | ||
| } | ||
|
|
||
| pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool, String> { | ||
| self.store | ||
| .verify_code(email, code) | ||
| .await | ||
| .map_err(|e| format!("Failed to verify code: {}", e)) | ||
| } | ||
|
|
||
| pub async fn is_verified(&self, email: &str) -> Result<bool, String> { | ||
| self.store | ||
| .is_verified(email) | ||
| .await | ||
| .map_err(|e| format!("Failed to check verification status: {}", e)) | ||
| } | ||
|
|
||
| pub async fn cleanup_expired(&self) -> Result<(), String> { | ||
| self.store | ||
| .cleanup_expired() | ||
| .await | ||
| .map_err(|e| format!("Failed to cleanup expired verifications: {}", e))?; | ||
| Ok(()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,11 @@ | ||
| mod email_verification; | ||
| mod email_verification_service; | ||
| mod new_subscriber; | ||
| mod repository_url; | ||
| mod subscriber_email; | ||
|
|
||
| pub use email_verification::*; | ||
| pub use email_verification_service::*; | ||
| pub use new_subscriber::*; | ||
| pub use repository_url::*; | ||
| pub use subscriber_email::*; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The expiration duration (10 minutes) is hardcoded in multiple places. Extract this value into a named constant to ensure consistency and ease future changes.