Browse Source

FEATURE: Email verification process on account registration

master
parent
commit
09710d48a9
  1. 1
      .gitignore
  2. 74
      pages/base.html
  3. 153
      pages/index.html
  4. 53
      pages/register.html
  5. 3
      scripts/api.js
  6. 47
      src/conf/conf.go
  7. 12
      src/db/db.go
  8. 29
      src/db/user.go
  9. 132
      src/db/verification.go
  10. 15
      src/email/auth.go
  11. 29
      src/email/email.go
  12. 16
      src/misc/codeNumeric.go
  13. 2
      src/misc/encryption.go
  14. 4
      src/server/api_test.go
  15. 129
      src/server/endpoints.go
  16. 19
      src/server/server.go
  17. 30
      src/server/validation.go

1
.gitignore vendored

@ -1,3 +1,4 @@
dela.zip dela.zip
bin/ bin/
TODO TODO
conf.json

74
pages/base.html

@ -6,51 +6,55 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dela</title> <title>Dela</title>
<link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon"> <link rel="shortcut icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<script src="/static/bootstrap/js/bootstrap.min.js"></script> <script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script>
<style> <style>
html * { html * {
font-family: "Roboto" !important; font-family: "Roboto", sans-serif !important;
src: url("/static/fonts/Roboto-Regular.ttf"); }
} body {
background-color: #f8f9fa;
}
header {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
</style> </style>
</head> </head>
<body class="w-100 h-100"> <body class="w-100 h-100">
<header class="p-3 text-bg-primary"> <header class="p-3 text-bg-warning-emphasis">
<div class="container"> <div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <div class="d-flex flex-wrap align-items-center justify-content-between">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none"> <a href="/" class="d-flex align-items-center text-white text-decoration-none">
<a href="/" class="d-inline-flex link-body-emphasis text-decoration-none"> <img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela">
<img width="64" height="64" src="/static/images/android-chrome-192x192.png" alt="Dela"> </a>
</a>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0"> <nav class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Main</a></li> <a href="/" class="nav-link px-2 text-white">Main</a>
<li><a href="/about" class="nav-link px-2 text-white">About</a></li> <a href="/about" class="nav-link px-2 text-white">About</a>
</ul> </nav>
<div class="text-end p-3"> <div class="d-flex align-items-center">
<button id="theme-switch-btn" class="btn btn-secondary" onclick="toggleTheme();"> <button id="theme-switch-btn" class="btn btn-secondary me-2" onclick="toggleTheme();">
<img id="theme-svg" src="/static/images/brightness-high.svg" alt="Change theme"> <img id="theme-svg" src="/static/images/brightness-high.svg" alt="Change theme">
</button> </button>
<div id="bar-auth" class="d-flex">
<a href="/login" class="btn btn-outline-light me-2">Login</a>
<a href="/register" class="btn btn-warning">Sign-up</a>
</div>
</div>
</div>
</div> </div>
<div class="text-end" id="bar-auth"> </header>
<a href="/login" class="btn btn-outline-light me-2">Login</a>
<a href="/register" class="btn btn-warning">Sign-up</a> <!-- Content -->
</div> <main class="container mt-4">
</div> {{ template "content" . }}
</div> </main>
</div>
</header>
<!-- Content -->
{{ template "content" . }}
</body> </body>
</html> </html>
<script src="/scripts/auth.js"></script> <script src="/scripts/auth.js"></script>
@ -100,4 +104,4 @@ document.addEventListener('DOMContentLoaded', async function() {
}, false) }, false)
</script> </script>
{{ end }} {{ end }}

153
pages/index.html

@ -3,91 +3,90 @@
{{ define "content" }} {{ define "content" }}
<!-- Main --> <!-- Main -->
<main> <main class="container mt-4">
<div class="d-flex flex-wrap">
<div class="d-flex flex-wrap"> <!-- Sidebar -->
<!-- Sidebar --> <aside id="sidebar" class="col-md-3 col-lg-2 border-end shadow-lg flex-shrink-1 p-3 d-flex flex-column align-items-stretch bg-light">
<div id="sidebar" class="col border-right shadow-lg flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary"> <a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom"> <img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg" alt="Logo">
<img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg"> <span class="fs-5 fw-semibold">Categories</span>
<span class="fs-5 fw-semibold">Categories</span> </a>
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</div>
{{ if not .Removable }}
<div class="col-10 mb-1 small">Not removable</div>
{{ end }}
</a>
{{ end }}
</div>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</div>
{{ if not .Removable }}
<div class="col-10 mb-1 small text-muted">Not removable</div>
{{ end }}
</a>
{{ end }}
</div>
<div class="input-group mb-3 py-md-5"> <div class="input-group mb-3 mt-3">
<input type="text" name="newCategory"aria-label="Category Name" aria-describedby="button-new-category" class="form-control" id="new-category-input" placeholder="Category Name"> <input type="text" name="newCategory" aria-label="Category Name" aria-describedby="button-new-category" class="form-control" id="new-category-input" placeholder="Category Name">
<button id="button-new-category" onclick="createNewCategory();" class="btn btn-primary">Create</button > <button id="button-new-category" onclick="createNewCategory();" class="btn btn-primary">Create</button>
</div> </div>
</div> </aside>
<!-- Groups --> <!-- Groups -->
<div class="d-flex flex-column flex-grow-1 flex-md-row p-4 gap-4 py-md-5"> <div class="col-md-9 col-lg-10 p-4">
<div class="list-group flex-grow-1"> <h2 class="mb-4">Available Categories</h2>
{{ range .Groups }} <div class="list-group">
<div class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true"> {{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3"> <div class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32"> <a href="/group/{{.ID}}" class="d-flex gap-3 w-100">
<div class="d-flex gap-2 w-100 justify-content-between"> <img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div> <div class="d-flex gap-2 w-100 justify-content-between">
<h6 class="mb-0">{{ .Name }}</h6> <div>
<p class="mb-0 opacity-75">Jump here</p> <h6 class="mb-0">{{ .Name }}</h6>
</div> <p class="mb-0 opacity-75">Jump here</p>
<small class="opacity-50 text-nowrap">{{ .TimeCreated }}</small> </div>
<small class="opacity-50 text-nowrap">{{ .TimeCreated }}</small>
</div>
</a>
{{ if .Removable }}
<div class="small">
<button class="btn btn-danger" onclick="deleteCategoryRefresh('{{.ID}}')">
<img src="/static/images/trash3-fill.svg" alt="Remove category" width="20">
</button>
</div>
{{ end }}
</div> </div>
</a>
{{ if .Removable }}
<div class="small">
<button class="btn btn-danger" onclick="deleteCategoryRefresh('{{.ID}}')">
<img src="/static/images/trash3-fill.svg" alt="Remove category">
</button>
</div>
{{ end }} {{ end }}
</div> </div>
{{ end }} </div>
</div>
</div> </div>
</div>
</main> </main>
<script>
async function createNewCategory() {
let categoryInput = document.getElementById("new-category-input");
let newCategoryName = categoryInput.value;
if (newCategoryName.length < 1) {
categoryInput.setCustomValidity("At least one character is needed!");
return;
} else {
categoryInput.setCustomValidity("");
}
categoryInput.value = "";
// Post new category and refresh
await postNewGroup({
Name: newCategoryName
});
window.location.reload();
}
async function deleteCategoryRefresh(id) {
await deleteCategory(id);
window.location.reload();
}
</script>
<script>
async function createNewCategory() {
let categoryInput = document.getElementById("new-category-input");
let newCategoryName = categoryInput.value;
if (newCategoryName.length < 1) {
categoryInput.setCustomValidity("At least one character is needed!");
return;
} else {
categoryInput.setCustomValidity("");
}
categoryInput.value = "";
// Post new category and refresh
await postNewGroup({
Name: newCategoryName
});
window.location.reload();
}
async function deleteCategoryRefresh(id) {
await deleteCategory(id);
window.location.reload();
}
</script>
{{ end }} {{ end }}

53
pages/register.html

@ -40,7 +40,51 @@
</div> </div>
</main> </main>
<div class="modal fade" id="verificationModal" tabindex="-1" aria-labelledby="verificationModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="verificationModalLabel">Email Verification</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Enter the verification code sent to your email address</p>
<div class="mb-3">
<label for="verificationCode" class="form-label">Verification Code</label>
<input type="text" id="input-code" class="form-control" id="verificationCode" placeholder="Enter your code" required>
</div>
<p class="text-danger"><span id="error-message-modal"></span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="verifyButton" onclick="verify();">Verify</button>
</div>
</div>
</div>
</div>
<script> <script>
function showVerificationModal() {
const verificationModal = new bootstrap.Modal(document.getElementById('verificationModal'), {});;
verificationModal.show();
}
async function verify() {
let emailInput = document.getElementById("input-email");
let email = String(emailInput.value).trim();
let codeInput = document.getElementById("input-code");
let code = String(codeInput.value).trim();
let response = await postEmailVerification(email, code);
if (response.ok) {
window.location.replace("/");
} else {
document.getElementById("error-message-modal").innerText = await response.text();
}
}
async function register() { async function register() {
let emailInput = document.getElementById("input-email"); let emailInput = document.getElementById("input-email");
if (!emailInput.reportValidity()) { if (!emailInput.reportValidity()) {
@ -62,9 +106,14 @@ async function register() {
}; };
let response = await postNewUser(postData); let response = await postNewUser(postData);
if (response.ok) { if (response.ok) {
window.location.replace("/"); let json = await response.json();
if (json.confirm_email) {
// Open email confirmation modal
showVerificationModal();
} else {
window.location.replace("/");
}
} else { } else {
document.getElementById("error_message").innerText = await response.text(); document.getElementById("error_message").innerText = await response.text();
} }

3
scripts/api.js

@ -13,6 +13,9 @@ async function post(url, json) {
}) })
} }
async function postEmailVerification(email, code) {
return post("/api/user/verify", {"email":email, "code":code});
}
async function postNewTodo(newTodo) { async function postNewTodo(newTodo) {
return post("/api/todo/create", newTodo) return post("/api/todo/create", newTodo)

47
src/conf/conf.go

@ -1,6 +1,6 @@
/* /*
dela - web TODO list dela - web TODO list
Copyright (C) 2023 Kasyanov Nikolay Alexeyevich (Unbewohnte) Copyright (C) 2023, 2025 Kasyanov Nikolay Alexeyevich (Unbewohnte)
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU Affero General Public License as published by
@ -24,20 +24,49 @@ import (
"os" "os"
) )
type ServerConf struct {
Port uint16 `json:"port"`
CertFilePath string `json:"cert_file_path"`
KeyFilePath string `json:"key_file_path"`
}
type EmailerConf struct {
User string `json:"user"`
Host string `json:"host"`
HostPort uint16 `json:"host_port"`
Password string `json:"password"`
}
type EmailVerificationConf struct {
VerifyEmails bool `json:"verify_emails"`
Emailer EmailerConf `json:"emailer"`
}
type Conf struct { type Conf struct {
Port uint16 `json:"port"` Server ServerConf `json:"server"`
CertFilePath string `json:"cert_file_path"` Verification EmailVerificationConf `json:"verification"`
KeyFilePath string `json:"key_file_path"` BaseContentDir string `json:"base_content_dir"`
BaseContentDir string `json:"base_content_dir"` ProdDBName string `json:"production_db_name"`
ProdDBName string `json:"production_db_name"`
} }
// Creates a default server configuration // Creates a default server configuration
func Default() Conf { func Default() Conf {
return Conf{ return Conf{
Port: 8080, Server: ServerConf{
CertFilePath: "", Port: 8080,
KeyFilePath: "", CertFilePath: "",
KeyFilePath: "",
},
Verification: EmailVerificationConf{
VerifyEmails: true,
Emailer: EmailerConf{
User: "you@example.com",
Host: "smtp.example.com",
HostPort: 587,
Password: "hostpassword",
},
},
BaseContentDir: ".", BaseContentDir: ".",
ProdDBName: "dela.db", ProdDBName: "dela.db",
} }

12
src/db/db.go

@ -42,6 +42,18 @@ func setUpTables(db *DB) error {
return err return err
} }
// User Email Verification
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS verifications(
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,
email TEXT NOT NULL,
code TEXT NOT NULL,
issued_unix INTEGER,
life_seconds INTEGER)`,
)
if err != nil {
return err
}
// Todo groups // Todo groups
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todo_groups( _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todo_groups(
id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE,

29
src/db/user.go

@ -110,3 +110,32 @@ func (db *DB) DeleteUserClean(email string) error {
return nil return nil
} }
// Sets confirmed_email to true for given user
func (db *DB) UserSetEmailConfirmed(email string) error {
_, err := db.Exec(
"UPDATE users SET confirmed_email=? WHERE email=?",
true,
email,
)
return err
}
// Cleanly deletes user if email is not confirmed
func (db *DB) DeleteUnverifiedUserClean(email string) error {
user, err := db.GetUser(email)
if err != nil {
return err
}
if !user.ConfirmedEmail {
// Email is not verified, delete information on this user
err = db.DeleteUserClean(email)
if err != nil {
return err
}
}
return nil
}

132
src/db/verification.go

@ -0,0 +1,132 @@
package db
import "database/sql"
type Verification struct {
ID uint64 `json:"id"`
Email string `json:"email"`
Code string `json:"code"`
IssuedUnix uint64 `json:"issued_unix"`
LifeSeconds uint64 `json:"life_seconds"`
}
func NewVerification(email string, code string, issuedUnix uint64, lifeSeconds uint64) *Verification {
return &Verification{
Email: email,
Code: code,
IssuedUnix: issuedUnix,
LifeSeconds: lifeSeconds,
}
}
func scanVerification(rows *sql.Rows) (*Verification, error) {
var newVerification Verification
err := rows.Scan(
&newVerification.ID,
&newVerification.Email,
&newVerification.Code,
&newVerification.IssuedUnix,
&newVerification.LifeSeconds,
)
if err != nil {
return nil, err
}
return &newVerification, nil
}
// Retrieves a verification with given Id from the database
func (db *DB) GetVerification(id uint64) (*Verification, error) {
rows, err := db.Query(
"SELECT * FROM verifications WHERE id=?",
id,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
verification, err := scanVerification(rows)
if err != nil {
return nil, err
}
return verification, nil
}
// Returns the first email verification by email
func (db *DB) GetVerificationByEmail(email string) (*Verification, error) {
rows, err := db.Query(
"SELECT * FROM verifications WHERE email=?",
email,
)
if err != nil {
return nil, err
}
defer rows.Close()
rows.Next()
verification, err := scanVerification(rows)
if err != nil {
return nil, err
}
return verification, nil
}
// Retrieves information on ALL TODOs
func (db *DB) GetVerifications() ([]*Verification, error) {
var verifications []*Verification
rows, err := db.Query("SELECT * FROM verifications")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
verification, err := scanVerification(rows)
if err != nil {
return verifications, err
}
verifications = append(verifications, verification)
}
return verifications, nil
}
// Creates a new verification in the database
func (db *DB) CreateVerification(verification Verification) error {
_, err := db.Exec(
"INSERT INTO verifications(email, code, issued_unix, life_seconds) VALUES(?, ?, ?, ?)",
verification.Email,
verification.Code,
verification.IssuedUnix,
verification.LifeSeconds,
)
return err
}
// Deletes information about a verification of certain ID from the database
func (db *DB) DeleteVerification(id uint64) error {
_, err := db.Exec(
"DELETE FROM verifications WHERE id=?",
id,
)
return err
}
// Updates verification
func (db *DB) UpdateVerification(verificationID uint64, updatedTodo Verification) error {
_, err := db.Exec(
"UPDATE verifications SET code=?, life_seconds=? WHERE id=?",
updatedTodo.Code,
updatedTodo.LifeSeconds,
verificationID,
)
return err
}

15
src/email/auth.go

@ -0,0 +1,15 @@
package email
import (
"Unbewohnte/dela/conf"
"net/smtp"
)
func Auth(conf conf.Conf) smtp.Auth {
return smtp.PlainAuth(
"",
conf.Verification.Emailer.User,
conf.Verification.Emailer.Password,
conf.Verification.Emailer.Host,
)
}

29
src/email/email.go

@ -13,15 +13,26 @@ type Email struct {
Body string Body string
} }
type MailSender struct { func NewEmail(sender, subject, body string, to []string) Email {
Auth smtp.Auth return Email{
From string Sender: sender,
To: to,
Subject: subject,
Body: body,
}
}
type Emailer struct {
Auth smtp.Auth
Address string
From string
} }
func NewMailSender(auth smtp.Auth, from string) MailSender { func NewEmailer(auth smtp.Auth, addr string, from string) *Emailer {
return MailSender{ return &Emailer{
Auth: auth, Auth: auth,
From: from, Address: addr,
From: from,
} }
} }
@ -35,7 +46,7 @@ func buildEmail(mail Email) []byte {
return []byte(message) return []byte(message)
} }
func (ms *MailSender) SendEmail(addr string, mail Email) error { func (em *Emailer) SendEmail(mail Email) error {
err := smtp.SendMail(addr, ms.Auth, ms.From, mail.To, buildEmail(mail)) err := smtp.SendMail(em.Address, em.Auth, em.From, mail.To, buildEmail(mail))
return err return err
} }

16
src/misc/codeNumeric.go

@ -0,0 +1,16 @@
package misc
import (
"math/rand"
"strconv"
)
// Generates a pseudo-random numeric code of required length
func GenerateNumericCode(length uint) string {
code := ""
for i := 0; uint(i) < length; i++ {
code += strconv.Itoa(rand.Intn(10))
}
return code
}

2
src/encryption/encryption.go → src/misc/encryption.go

@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package encryption package misc
import ( import (
"crypto/sha256" "crypto/sha256"

4
src/server/api_test.go

@ -63,7 +63,7 @@ func TestApi(t *testing.T) {
t.Fatalf("could not marshal new user JSON: %s", err) t.Fatalf("could not marshal new user JSON: %s", err)
} }
resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/user", config.Port), "application/json", bytes.NewBuffer(newUserJsonBytes)) resp, err := http.Post(fmt.Sprintf("http://localhost:%d/api/user", config.Server.Port), "application/json", bytes.NewBuffer(newUserJsonBytes))
if err != nil { if err != nil {
t.Fatalf("failed to post a new user data: %s", err) t.Fatalf("failed to post a new user data: %s", err)
} }
@ -124,7 +124,7 @@ func TestApi(t *testing.T) {
t.Fatalf("could not marshal new Todo: %s", err) t.Fatalf("could not marshal new Todo: %s", err)
} }
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/todo", config.Port), bytes.NewBuffer(newTodoBytes)) req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/todo", config.Server.Port), bytes.NewBuffer(newTodoBytes))
if err != nil { if err != nil {
t.Fatalf("failed to create a new POST request to create a new TODO: %s", err) t.Fatalf("failed to create a new POST request to create a new TODO: %s", err)
} }

129
src/server/endpoints.go

@ -20,6 +20,7 @@ package server
import ( import (
"Unbewohnte/dela/db" "Unbewohnte/dela/db"
"Unbewohnte/dela/email"
"Unbewohnte/dela/logger" "Unbewohnte/dela/logger"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -81,10 +82,127 @@ func (s *Server) EndpointUserCreate(w http.ResponseWriter, req *http.Request) {
)) ))
if err != nil { if err != nil {
http.Error(w, "Failed to create default group", http.StatusInternalServerError) http.Error(w, "Failed to create default group", http.StatusInternalServerError)
logger.Error("[Server][EndpojntUserCreate] Failed to create a default group for %s: %s", user.Email, err) logger.Error("[Server][EndpointUserCreate] Failed to create a default group for %s: %s", user.Email, err)
return return
} }
// Check if email verification is required
if !s.config.Verification.VerifyEmails {
// Do not verify email
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Email, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: false,
})
// Done
w.Write([]byte("{\"confirm_email\":false}"))
logger.Info("[Server][EndpointUserCreate] Successfully sent email notification to %s", user.Email)
return
}
// Send email verification message
verification, err := GenerateVerificationCode(s.db, user.Email)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to generate verification code for %s: %s", user.Email, err)
http.Error(w, "Failed to generate confirmation code", http.StatusInternalServerError)
return
}
// Send verification email
err = s.emailer.SendEmail(
email.NewEmail(
s.config.Verification.Emailer.User,
"Dela: Email verification",
fmt.Sprintf("Your email verification code: <b>%s</b>\nPlease, verify your email in %f hours.\nThis email was specified during Dela account creation. Ignore this message if it wasn't you", verification.Code, float32(verification.LifeSeconds)/3600),
[]string{user.Email},
),
)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to send verification email to %s: %s", user.Email, err)
http.Error(w, "Failed to send email verification message", http.StatusInternalServerError)
return
}
// Autodelete user account if email was not verified in time
time.AfterFunc(time.Second*time.Duration(verification.LifeSeconds), func() {
err = s.db.DeleteUnverifiedUserClean(user.Email)
if err != nil {
logger.Error("[Server][EndpointUserCreate] Failed to autodelete unverified user %s: %s", user.Email, err)
}
})
w.Write([]byte("{\"confirm_email\":true}"))
}
func (s *Server) EndpointUserVerify(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Retrieve data
defer req.Body.Close()
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
type verificationAnswer struct {
Email string `json:"email"`
Code string `json:"code"`
}
var answer verificationAnswer
err = json.Unmarshal(contents, &answer)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to unmarshal verification answer: %s", err)
http.Error(w, "Verification answer JSON unmarshal error", http.StatusInternalServerError)
return
}
// Retrieve user
user, err := s.db.GetUser(answer.Email)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Failed to retrieve information on \"%s\": %s", answer.Email, err)
http.Error(w, "Failed to get user information", http.StatusInternalServerError)
return
}
// Compare codes
dbCode, err := s.db.GetVerificationByEmail(user.Email)
if err != nil {
logger.Error("[Server][EndpointUserVerify] Could not get verification code from DB for %s: %s", user.Email, err)
http.Error(w, "Could not retrieve verification information for this email", http.StatusInternalServerError)
return
}
if answer.Code != dbCode.Code {
// Codes do not match!
logger.Error("[Server][EndpointUserVerify] %s sent wrong verification code", user.Email)
http.Error(w, "Wrong verification code!", http.StatusForbidden)
return
}
// All's good!
err = s.db.UserSetEmailConfirmed(user.Email)
if err != nil {
http.Error(w, "Failed to save confirmation information", http.StatusInternalServerError)
logger.Error("[Server][EndpointUserVerify] Failed to set confirmed_email to true for %s: %s", user.Email, err)
return
}
logger.Info("[Server][EndpointUserVerify] %s was successfully verified!", user.Email)
// Send cookie // Send cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "auth", Name: "auth",
@ -122,14 +240,7 @@ func (s *Server) EndpointUserLogin(w http.ResponseWriter, req *http.Request) {
} }
// Check auth data // Check auth data
userDB, err := s.db.GetUser(user.Email) if !IsUserAuthorized(s.db, user) {
if err != nil {
logger.Error("[Server][EndpointUserLogin] Failed to fetch user information from DB: %s", err)
http.Error(w, "Failed to fetch user information", http.StatusInternalServerError)
return
}
if user.Password != userDB.Password {
http.Error(w, "Failed auth", http.StatusForbidden) http.Error(w, "Failed auth", http.StatusForbidden)
return return
} }

19
src/server/server.go

@ -21,6 +21,7 @@ package server
import ( import (
"Unbewohnte/dela/conf" "Unbewohnte/dela/conf"
"Unbewohnte/dela/db" "Unbewohnte/dela/db"
"Unbewohnte/dela/email"
"Unbewohnte/dela/logger" "Unbewohnte/dela/logger"
"context" "context"
"fmt" "fmt"
@ -45,6 +46,7 @@ type Server struct {
db *db.DB db *db.DB
http http.Server http http.Server
cookieJar *cookiejar.Jar cookieJar *cookiejar.Jar
emailer *email.Emailer
} }
// Creates a new server instance with provided config // Creates a new server instance with provided config
@ -86,7 +88,7 @@ func New(config conf.Conf) (*Server, error) {
// start constructing an http server configuration // start constructing an http server configuration
server.http = http.Server{ server.http = http.Server{
Addr: fmt.Sprintf(":%d", server.config.Port), Addr: fmt.Sprintf(":%d", server.config.Server.Port),
} }
// configure paths' callbacks // configure paths' callbacks
@ -218,6 +220,7 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific
mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific
mux.HandleFunc("/api/user/login", server.EndpointUserLogin) // Non specific mux.HandleFunc("/api/user/login", server.EndpointUserLogin) // Non specific
mux.HandleFunc("/api/user/verify", server.EndpointUserVerify) // Non specific
mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific
@ -232,6 +235,12 @@ func New(config conf.Conf) (*Server, error) {
jar, _ := cookiejar.New(nil) jar, _ := cookiejar.New(nil)
server.cookieJar = jar server.cookieJar = jar
server.emailer = email.NewEmailer(
email.Auth(server.config),
fmt.Sprintf("%s:%d", server.config.Verification.Emailer.Host, server.config.Verification.Emailer.HostPort),
server.config.Verification.Emailer.User,
)
logger.Info("[Server] Created an HTTP server instance") logger.Info("[Server] Created an HTTP server instance")
return &server, nil return &server, nil
@ -239,18 +248,18 @@ func New(config conf.Conf) (*Server, error) {
// Launches server instance // Launches server instance
func (s *Server) Start() error { func (s *Server) Start() error {
if s.config.CertFilePath != "" && s.config.KeyFilePath != "" { if s.config.Server.CertFilePath != "" && s.config.Server.KeyFilePath != "" {
logger.Info("[Server] Using TLS") logger.Info("[Server] Using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port) logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port)
err := s.http.ListenAndServeTLS(s.config.CertFilePath, s.config.KeyFilePath) err := s.http.ListenAndServeTLS(s.config.Server.CertFilePath, s.config.Server.KeyFilePath)
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
logger.Error("[Server] Fatal server error: %s", err) logger.Error("[Server] Fatal server error: %s", err)
return err return err
} }
} else { } else {
logger.Info("[Server] Not using TLS") logger.Info("[Server] Not using TLS")
logger.Info("[Server] HTTP server is going live on port %d!", s.config.Port) logger.Info("[Server] HTTP server is going live on port %d!", s.config.Server.Port)
err := s.http.ListenAndServe() err := s.http.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {

30
src/server/validation.go

@ -20,9 +20,11 @@ package server
import ( import (
"Unbewohnte/dela/db" "Unbewohnte/dela/db"
"Unbewohnte/dela/misc"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
) )
const ( const (
@ -52,13 +54,17 @@ func IsUserValid(user db.User) (bool, string) {
return true, "" return true, ""
} }
// Checks if such a user exists and compares passwords. Returns true if such user exists and passwords do match // Checks if such user exists, passwords match and email is confirmed. Returns true if such user exists, passwords do match and email was verified
func IsUserAuthorized(db *db.DB, user db.User) bool { func IsUserAuthorized(db *db.DB, user db.User) bool {
userDB, err := db.GetUser(user.Email) userDB, err := db.GetUser(user.Email)
if err != nil { if err != nil {
return false return false
} }
if !userDB.ConfirmedEmail {
return false
}
if userDB.Password != user.Password { if userDB.Password != user.Password {
return false return false
} }
@ -82,8 +88,8 @@ func AuthFromCookie(cookie *http.Cookie) (string, string) {
/* /*
Gets auth information from a request and Gets auth information from a request and
checks if such a user exists and compares passwords. checks if such user exists and passwords match.
Returns true if such user exists and passwords do match Returns true if such user exists, passwords do match and email is confirmed
*/ */
func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool { func IsUserAuthorizedReq(req *http.Request, dbase *db.DB) bool {
var email, password string var email, password string
@ -118,3 +124,21 @@ func GetLoginFromReq(req *http.Request) string {
return email return email
} }
/*
Generates a new verification code for given email with 8-digit numeric code,
current issue time and one hour lifetime.
Inserts newly created email verification into database.
*/
func GenerateVerificationCode(dbase *db.DB, email string) (*db.Verification, error) {
verification := db.NewVerification(
email, misc.GenerateNumericCode(8), uint64(time.Now().Unix()), uint64(time.Hour.Seconds()),
)
err := dbase.CreateVerification(*verification)
if err != nil {
return nil, err
}
return verification, nil
}

Loading…
Cancel
Save