Compare commits

..

4 Commits

Author SHA1 Message Date
21c5f42aff Sending email 2024-08-20 22:36:16 +02:00
6edfc529ea Password resets and sending mail 2024-08-20 22:20:37 +02:00
d256bfb438 Password resets 2024-08-20 22:06:04 +02:00
a6d54242bb Protecting routes on hte FE and improving authentication 2024-08-20 21:45:13 +02:00
21 changed files with 305 additions and 13 deletions

View File

@ -25,6 +25,12 @@ type config struct {
secret string
key string
}
smtp struct {
host string
port int
username string
password string
}
}
type application struct {
@ -69,6 +75,10 @@ func main() {
"root:example@tcp(localhost:3306)/widgets?parseTime=true&tls=false",
"Application environment {development|production}",
)
flag.StringVar(&cfg.smtp.host, "smtphost", "0.0.0.0", "smtp host")
flag.IntVar(&cfg.smtp.port, "smtpport", 1025, "smtp host")
flag.StringVar(&cfg.smtp.username, "smtpuser", "user", "smtp user")
flag.StringVar(&cfg.smtp.password, "smtppwd", "password", "smtp password")
flag.Parse()

View File

@ -405,3 +405,23 @@ func (app *application) VirtualTerminalPaymentSucceeded(w http.ResponseWriter, r
app.writeJSON(w, http.StatusOK, txn)
}
func (app *application) SendPasswordResetEmail(w http.ResponseWriter, r *http.Request) {
var payload struct {
Email string `json:"email"`
}
err := app.readJSON(w, r, &payload)
if err != nil {
app.badRequest(w, r, err)
return
}
var data struct {
Link string
}
data.Link = "http://www.vinchent.xyz"
// send mail
}

78
cmd/api/mailer.go Normal file
View File

@ -0,0 +1,78 @@
package main
import (
"bytes"
"embed"
"fmt"
"text/template"
"time"
mail "github.com/xhit/go-simple-mail/v2"
)
//go:embed templates
var emailTemplateFS embed.FS
func (app *application) SendMail(from, to, subject, tmpl string, data interface{}) error {
templateToRender := fmt.Sprintf("templates/%s.html.tmpl", tmpl)
t, err := template.New("email-html").ParseFS(emailTemplateFS, templateToRender)
if err != nil {
app.errorLog.Println(err)
return err
}
var tpl bytes.Buffer
if err = t.ExecuteTemplate(&tpl, "body", data); err != nil {
app.errorLog.Println(err)
return err
}
formattedMessage := tpl.String()
templateToRender = fmt.Sprintf("templates/%s.plain.tmpl", tmpl)
t, err = template.New("email-plain").ParseFS(emailTemplateFS, templateToRender)
if err != nil {
app.errorLog.Println(err)
return err
}
if err = t.ExecuteTemplate(&tpl, "body", data); err != nil {
app.errorLog.Println(err)
return err
}
plainMessage := tpl.String()
app.infoLog.Println(formattedMessage, plainMessage)
// send the mail
server := mail.NewSMTPClient()
server.Host = app.config.smtp.host
server.Port = app.config.smtp.port
// NOTE: not needed for MailHog
// server.Username = app.config.smtp.username
// server.Password = app.config.smtp.password
// server.Encryption = mail.EncryptionTLS
server.KeepAlive = false
server.ConnectTimeout = 10 * time.Second
server.SendTimeout = 10 * time.Second
smtpClient, err := server.Connect()
if err != nil {
return err
}
email := mail.NewMSG()
email.SetFrom(from).AddTo(to).SetSubject(subject)
email.SetBody(mail.TextHTML, formattedMessage)
email.AddAlternative(mail.TextPlain, plainMessage)
err = email.Send(smtpClient)
if err != nil {
app.errorLog.Println(err)
return err
}
app.infoLog.Println("send mail")
return nil
}

View File

@ -33,6 +33,7 @@ func (app *application) routes() http.Handler {
mux.Post("/virtual-terminal-succeeded", app.VirtualTerminalPaymentSucceeded)
})
mux.Post("/api/forgot-password", app.SendPasswordResetEmail)
return mux
}

View File

@ -289,3 +289,38 @@ func (app *application) LoginPage(w http.ResponseWriter, r *http.Request) {
app.errorLog.Println(err)
}
}
func (app *application) PostLoginPage(w http.ResponseWriter, r *http.Request) {
app.Session.RenewToken(r.Context())
err := r.ParseForm()
if err != nil {
app.errorLog.Println(err)
return
}
email := r.Form.Get("email")
password := r.Form.Get("password")
id, err := app.DB.Authenticate(email, password)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
app.Session.Put(r.Context(), "userID", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) Logout(w http.ResponseWriter, r *http.Request) {
app.Session.Destroy(r.Context())
app.Session.RenewToken(r.Context())
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (app *application) ForgotPassword(w http.ResponseWriter, r *http.Request) {
if err := app.renderTemplate(w, r, "forgot-password", &templateData{}); err != nil {
app.errorLog.Println(err)
}
}

View File

@ -12,6 +12,7 @@ import (
"os"
"time"
"github.com/alexedwards/scs/mysqlstore"
"github.com/alexedwards/scs/v2"
)
@ -91,16 +92,17 @@ func main() {
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
errorLog := log.New(os.Stdout, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
// set up session
session = scs.New()
session.Lifetime = 24 * time.Hour
conn, err := driver.OpenDB(cfg.db.dsn)
if err != nil {
errorLog.Fatal(err)
}
defer conn.Close()
// set up session
session = scs.New()
session.Lifetime = 24 * time.Hour
session.Store = mysqlstore.New(conn)
tc := make(map[string]*template.Template)
app := &application{

View File

@ -7,3 +7,12 @@ import (
func SessionLoad(next http.Handler) http.Handler {
return session.LoadAndSave(next)
}
func (app *application) Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.Session.Exists(r.Context(), "userID") {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
next.ServeHTTP(w, r)
})
}

View File

@ -11,9 +11,14 @@ func (app *application) routes() http.Handler {
mux.Use(SessionLoad)
mux.Get("/", app.Home)
mux.Route("/admin", func(mux chi.Router) {
mux.Use(app.Auth)
mux.Get("/virtual-terminal", app.VirtualTerminal)
mux.Post("/virtual-terminal-payment-succeeded", app.VirtualTerminalPaymentSucceeded)
mux.Get("/virtual-terminal-receipt", app.VirtualTerminalReceipt)
})
// mux.Post("/virtual-terminal-payment-succeeded", app.VirtualTerminalPaymentSucceeded)
// mux.Get("/virtual-terminal-receipt", app.VirtualTerminalReceipt)
mux.Get("/widget/{id}", app.ChargeOnce)
mux.Get("/receipt", app.Receipt)
@ -24,6 +29,9 @@ func (app *application) routes() http.Handler {
// auth routes
mux.Get("/login", app.LoginPage)
mux.Post("/login", app.PostLoginPage)
mux.Get("/logout", app.Logout)
mux.Get("/forgot-password", app.ForgotPassword)
fileServer := http.FileServer(http.Dir("./static"))
mux.Handle("/static/*", http.StripPrefix("/static", fileServer))

View File

@ -48,7 +48,7 @@
</ul>
</li>
<li id="vt-link" class="nav-item d-none">
<a class="nav-link" href="/virtual-terminal">Virtual Terminal</a>
<a class="nav-link" href="/admin/virtual-terminal">Virtual Terminal</a>
</li>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li id="login-link" class="nav-item d-none">

View File

@ -0,0 +1,41 @@
{{template "base" .}}
{{define "title"}}
Forgot Password
{{end}}
{{define "content"}}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="alert alert-danger text-center d-none" id="forgot-messages"></div>
<form action=""
method="post"
name="forgot-form"
id="forgot-form"
class="d-blick needs-validation"
autocomplete="off"
novalidate="">
<h2 class="mt-2 mb-3 text-center">Forgot Password</h2>
<hr>
<div class="mb-3">
<label for="-email" class="form-label">Email</label>
<input type="text"
id="email"
name="email"
autocomplete="email-new"
required=""
class="form-control">
</div>
<hr>
<a href="javascript:void(0)" id="reset-btn" class="btn btn-primary">Reset Password</a>
</form>
</div>
</div>
{{end}}
{{define "js"}}
<script type="module">
import {forgot} from "/static/js/login.js"
document.getElementById("reset-btn").addEventListener("click", () => {
forgot({{.API}});
})
</script>
{{end}}

View File

@ -6,7 +6,7 @@ Login
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="alert alert-danger text-center d-none" id="login-messages"></div>
<form action=""
<form action="/login"
method="post"
name="login-form"
id="login-form"
@ -35,6 +35,9 @@ Login
</div>
<hr>
<a href="javascript:void(0)" id="login-btn" class="btn btn-primary">Login</a>
<p class="mt-2">
<small><a href="/forgot-password">Forgot password</a></small>
</p>
</form>
</div>
</div>

View File

@ -80,7 +80,7 @@ Virtual Terminal
<strong>Bank Return Code</strong>: <span id="bank-return-code"></span>
</p>
<p>
<a href="/virtual-terminal" class="btn btn-primary">
<a href="/admin/virtual-terminal" class="btn btn-primary">
Charge another card
</a>
</p>

8
go.mod
View File

@ -3,12 +3,18 @@ module myapp
go 1.22.5
require (
github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885
github.com/alexedwards/scs/v2 v2.8.0
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-sql-driver/mysql v1.8.1
github.com/stripe/stripe-go/v79 v79.6.0
github.com/xhit/go-simple-mail/v2 v2.16.0
golang.org/x/crypto v0.26.0
)
require filippo.io/edwards25519 v1.1.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
)

9
go.sum
View File

@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885 h1:C7QAamNjR5yz6di4KJWAKcnxueKBgq4L/JGXhlnu35w=
github.com/alexedwards/scs/mysqlstore v0.0.0-20240316134038-7e11d57e8885/go.mod h1:p8jK3D80sw1PFrCSdlcJF1O75bp55HqbgDyyCLM0FrE=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
@ -8,8 +10,11 @@ github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -17,6 +22,10 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stripe/stripe-go/v79 v79.6.0 h1:qSBV2f2rpLEEZTdTlVLzdmQJZNmfoo2E3hUEkFT8GBc=
github.com/stripe/stripe-go/v79 v79.6.0/go.mod h1:cuH6X0zC8peY6f1AubHwgJ/fJSn2dh5pfiCr6CjyKVU=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

View File

@ -3,8 +3,11 @@ package models
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// DBModel is the type for database connection values
@ -254,3 +257,26 @@ func (m *DBModel) GetUserByEmail(email string) (User, error) {
return u, nil
}
func (m *DBModel) Authenticate(email, password string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var id int
var hashedPassword string
row := m.DB.QueryRowContext(ctx, "SELECT id, password from users WHERE email = ?", email)
err := row.Scan(&id, &hashedPassword)
if err != nil {
return 0, err
}
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
if err == bcrypt.ErrMismatchedHashAndPassword {
return 0, errors.New("incorrect password")
} else if err != nil {
return 0, err
}
return id, nil
}

View File

@ -0,0 +1 @@
DROP TABLE sessions;

View File

@ -0,0 +1,7 @@
CREATE TABLE sessions (
token CHAR(43) PRIMARY KEY,
data BLOB NOT NULL,
expiry TIMESTAMP(6) NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions (expiry);

View File

@ -14,6 +14,6 @@ document.addEventListener("DOMContentLoaded", function () {
function logout() {
localStorage.removeItem("token");
localStorage.removeItem("token_expiry");
location.href = "/login";
location.href = "/logout";
}

View File

@ -36,7 +36,8 @@ export function val(api) {
localStorage.setItem("token", response.authentication_token.token);
localStorage.setItem("token_expiry", response.authentication_token.expiry);
showSuccess("login-messages", "Login successful.")
location.href = "/";
// location.href = "/";
document.getElementById("login-form").submit()
} else {
showError("login-messages", response.message)
}
@ -44,3 +45,38 @@ export function val(api) {
}
export function forgot(api) {
let form = document.getElementById("forgot-form");
if (form.checkValidity() === false) {
// this.event.preventDefault();
// this.event.stopPropagation();
form.classList.add("was-validated");
return;
}
form.classList.add("was-validated");
let payload = {
email: document.getElementById("email").value,
};
const requestOptions = {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
};
fetch(api + "/api/forgot-password", requestOptions)
.then(response => response.json())
.then(response => {
console.log(response)
if (response.error === false) {
showSuccess("forgot-messages", "Password reset email sent")
} else {
showError("forgot-messages", response.message)
}
});
}