added login page

This commit is contained in:
drevil 2023-07-01 19:18:25 -04:00
parent c0d546b167
commit b108615af0
8 changed files with 415 additions and 73 deletions

View File

@ -47,6 +47,9 @@ type Options struct {
UploadSize int64
SiteURL string
SecretPath string
Username string
Password string
CreateUser bool
}
type Comic struct {
@ -66,16 +69,17 @@ type Context struct {
Image string
}
Comic *Comic
Current int
Previous int
Combo int
Next int
First int
Last int
Title string
UserName string
SiteURL string
Comic *Comic
Current int
Previous int
Combo int
Next int
First int
Last int
Title string
UserName string
SiteURL string
Message string
}
type Err struct {
@ -95,9 +99,16 @@ func (o * Options) Parse() error {
flag.Int64Var(&o.UploadSize, "z", 10, "Max upload size for posts")
flag.StringVar(&o.SiteURL, "0", "https://comics.blackram.works", "URL of the site once deployed")
flag.StringVar(&o.SecretPath, "s", "./secret.key", "Sets path to secret key file")
flag.StringVar(&o.Username, "w", "", "Username for the new user")
flag.StringVar(&o.Password, "q", "", "Password for the new user")
flag.BoolVar(&o.CreateUser, "c", false, "Creates a new user. Needs -w and -q")
flag.Parse()
if o.CreateUser {
o.RunManager = true
}
if _, err := os.Stat(o.TemplatesPath); errors.Is(err, os.ErrNotExist) {
log.Fatal(err)
}
@ -443,9 +454,19 @@ func main() {
fs = http.FileServer(http.Dir(options.MediaPath))
http.Handle("/media/", http.StripPrefix("/media/", fs))
// errors
http.HandleFunc("/401", return401)
http.HandleFunc("/404", return404)
http.HandleFunc("/500", return500)
// manager
if options.RunManager {
log.Fatal(startManager(options.Address, options.Port, options.DBPath,
options.MediaPath))
err = startManager(options.Address, options.Port, options.DBPath, options.MediaPath)
if err != nil {
log.Fatal(err)
}
return
}
@ -458,10 +479,6 @@ func main() {
http.HandleFunc("/all", allView)
http.HandleFunc("/blog", firstView)
// errors
http.HandleFunc("/404", return404)
http.HandleFunc("/500", return500)
uri := fmt.Sprintf("%s:%d", options.Address, options.Port)
log.Println("listening to http://" + uri)
log.Fatal(http.ListenAndServe(uri, logRequest(http.DefaultServeMux)))

View File

@ -7,11 +7,13 @@ import (
"fmt"
"path"
"time"
"errors"
"strconv"
"strings"
"net/http"
"math/rand"
"database/sql"
"crypto/sha256"
"path/filepath"
"github.com/golang-jwt/jwt"
@ -20,20 +22,33 @@ import (
var seededRand *rand.Rand = nil
var secretKey string = ""
var secretKeyHash string = ""
const authSquema string = `
const userSquema string = `
CREATE TABLE IF NOT EXISTS user (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
id CHAR(20) NOT NULL PRIMARY KEY,
name CHAR(20),
password CHAR(100)
password CHAR(50)
);`
const tokenSquema string = `
CREATE TABLE IF NOT EXISTS token (
id CHAR(20) NOT NULL PRIMARY KEY,
expiry INTEGER NOT NULL,
username CHAR(20) NOT NULL
);`
type User struct {
ID int
ID string
Name string
Password string
}
type ManagerContext struct {
Context
User User
}
type ConfirmContext struct {
Action string
Message string
@ -41,25 +56,149 @@ type ConfirmContext struct {
OnNo string
}
type TokenClaims struct {
jwt.StandardClaims
ID string
Expiry time.Time
Username string
}
type ViewHandler func(w http.ResponseWriter, r *http.Request)
func (u *User) readRow(db * sql.Rows) error {
return db.Scan(&u.ID, &u.Name, &u.Password)
}
func generateToken(username string) (string, error) {
token := jwt.New(jwt.SigningMethodEdDSA)
claims := token.Claims.(jwt.MapClaims)
claims["exp"] = time.Now().Add(10 * time.Minute)
claims["authorized"] = true
claims["user"] = username
tokenString, err := token.SignedString(secretKey)
if err != nil {
return "Signing Error", err
func (t *TokenClaims) readRow(db * sql.Rows) error {
var e int64
err := db.Scan(&t.ID, &e, &t.Username)
t.Expiry = time.Unix(e, 0)
return err
}
func generateToken(username string, seconds time.Duration) (string, *TokenClaims, error) {
claims := TokenClaims{
ID: randomString(20),
Expiry: time.Now().Add(seconds * time.Second).Truncate((time.Second)),
Username: username,
}
return tokenString, nil
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secretKey))
if err != nil {
return "", nil, err
}
err = cleannupTokens()
if err != nil {
return "", nil, err
}
_, err = db.Exec("INSERT INTO token(id, expiry, username) VALUES(?, ?, ?);",
claims.ID, claims.Expiry.Unix(), claims.Username)
if err != nil {
return "", nil, err
}
return tokenString, &claims, nil
}
func cleannupTokens() error {
_, err := db.Exec("DELETE FROM token WHERE expiry < ?;", time.Now().Unix())
return err
}
func verifyToken(token string) (*TokenClaims, error) {
claims := &TokenClaims{}
t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
return []byte(secretKey), nil
})
if err != nil {
return nil, err
}
if !t.Valid {
return nil, errors.New("invalid token")
}
err = cleannupTokens()
if err != nil {
return nil, err
}
rows, err := db.Query("SELECT * FROM token WHERE id = ?;", claims.ID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, errors.New(fmt.Sprintf("no such claim with id \"%s\" was found", claims.ID))
}
claimsOnDB := &TokenClaims{}
err = claimsOnDB.readRow(rows)
if err != nil {
return nil, err
}
if !(claimsOnDB.ID == claims.ID && claims.Expiry == claimsOnDB.Expiry &&
claims.Username == claimsOnDB.Username) {
log.Println(*claims)
log.Println(*claimsOnDB)
return nil, errors.New("claims do not match database records")
}
return claims, nil
}
func revokeToken(id string) error {
_, err := db.Exec("DELETE FROM token WHERE id = ?;", id)
return err
}
func saltPassword(password string, id string) string {
s := password + id + secretKeyHash
if len(s) > 50 {
s = s[:50]
}
return s
}
func addUser(name string, password string) (*User, error) {
id := randomString(20)
var user *User = nil
users, err := getAllUsers()
if err != nil {
return nil, err
}
for user == nil {
user = &User{}
for _, u := range(users) {
if name == u.Name {
return nil, errors.New("user with the same name already exists")
}
if id == u.ID {
id = randomString(20)
user = nil
break
}
}
}
p, err := bcrypt.GenerateFromPassword([]byte(saltPassword(password, id)), 8)
_, err = db.Exec("INSERT INTO user(id, name, password) VALUES(?, ?, ?);",
id, name, string(p))
if err != nil {
return nil, err
}
return user, nil
}
func getAllUsers() ([]User, error) {
@ -74,7 +213,7 @@ func getAllUsers() ([]User, error) {
u := User{}
err := u.readRow(rows)
if err != nil {
return users, err
return nil, err
}
users = append(users, u)
}
@ -91,6 +230,81 @@ func randomString(length int) string {
return string(b)
}
func getSessionToken(r *http.Request) (string, error) {
c, err := r.Cookie("token")
if err != nil {
return "", err
}
return c.Value, err
}
func getSessionUser(r * http.Request) (*User, *TokenClaims, error) {
c, err := getSessionToken(r)
if err != nil {
return nil, nil, err
}
return authenticateUserByToken(c)
}
func authenticateUserByToken(token string) (*User, *TokenClaims, error) {
claims, err := verifyToken(token)
if err != nil {
return nil, nil, err
}
users, _ := getAllUsers()
var user *User = nil
for i, u := range(users) {
if u.Name == claims.Username {
user = &users[i]
break
}
}
if user == nil {
err = errors.New("no such user \"" + claims.Username + "\"")
}
return user, claims, nil
}
func athenticateUserByPassword(username string, password string) (*User, error) {
if len(username) >= 20 {
username = username[:20]
}
if len(password) >= 50 {
password = password[:50]
}
users, err := getAllUsers()
if err != nil {
return nil, err
}
var user *User = nil
for i, u := range(users) {
if u.Name == username {
user = &users[i]
break
}
}
if user == nil {
return nil, errors.New("No such user \"" + username + "\" found")
}
log.Println(user.ID)
password = saltPassword(password, user.ID)
if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, err
}
return user, err
}
func managerIndexView(w http.ResponseWriter, r * http.Request) {
var err error
@ -103,14 +317,22 @@ func managerIndexView(w http.ResponseWriter, r * http.Request) {
}
} ()
user, _, err := getSessionUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if len(strings.TrimPrefix(r.URL.Path, "/")) > 0 {
return404(w,r)
return
}
context := Context{
Comics: nil,
Title: "Black Ram Comics Manager",
context := ManagerContext{
Context: Context{
Title: "Black Ram Comics Manager",
},
User: *user,
}
comics, err := allComics()
@ -226,7 +448,16 @@ func managerPublishView(w http.ResponseWriter, r * http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func loginView(w http.ResponseWriter, r * http.Request) {
func managerLoginView(w http.ResponseWriter, r * http.Request) {
context := Context{
Title: "Blackram Manager",
}
if r.Method == "GET" {
executeTemplate(w, "login", context)
return
}
if r.Method != "POST" {
return404(w, r)
return
@ -235,36 +466,58 @@ func loginView(w http.ResponseWriter, r * http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
context.Message = "missing name/password"
executeTemplate(w, "login", context)
return
}
user, err := athenticateUserByPassword(username, password)
if err != nil {
context.Message = "wrong username/password"
executeTemplate(w, "login", context)
return
}
token, claims, err := generateToken(user.Name, 60)
if err != nil {
log.Println(fmt.Sprintf("failed to generate token for \"%s\": %s", user.Name, err.Error()))
return500(w, r)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Expires: claims.Expiry,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func managerLogoutView(w http.ResponseWriter, r * http.Request) {
if r.Method != "GET" {
return404(w, r)
return
}
if len(username) >= 20 {
username = username[:20]
}
if len(password) >= 100 {
password = password[:100]
}
users, _ := getAllUsers()
var user *User = nil
for i, u := range(users) {
if u.Name == username {
user = &users[i]
break
}
}
if user == nil {
_, claims, err := getSessionUser(r)
if err != nil {
log.Println("failed to get session user: " + err.Error())
return401(w, r)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return401(w, r)
err = revokeToken(claims.ID)
if err != nil {
log.Println("failed to revoke session token: " + err.Error())
return500(w, r)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: "",
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
@ -272,19 +525,24 @@ func managerEditView(w http.ResponseWriter, r * http.Request) {
}
func startManager(address string, port int, dbPath string, mediaPath string) error {
_, err := db.Exec(authSquema)
seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
_, err := db.Exec(userSquema)
if err != nil {
log.Fatal(err)
}
_, err = db.Exec(tokenSquema)
if err != nil {
log.Fatal(err)
}
seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
_, err = os.Stat(options.SecretPath)
if os.IsNotExist(err) {
secretKey = randomString(100)
err = os.WriteFile(options.SecretPath, []byte(secretKey), 0600)
if err != nil {
log.Fatal(fmt.Sprintf("failed to create random key: %s", err))
log.Fatal(fmt.Sprintf("failed to create random key on path \"%s\": %s",
options.SecretPath, err))
}
} else {
s, err := os.ReadFile(options.SecretPath)
@ -293,11 +551,25 @@ func startManager(address string, port int, dbPath string, mediaPath string) err
log.Fatal(fmt.Sprintf("failed to read random key: %s", err))
}
}
sh := sha256.Sum256([]byte(secretKey))
secretKeyHash = fmt.Sprintf("%x", sh)
if options.CreateUser {
_, err := addUser(options.Username, options.Password)
if err != nil {
log.Fatal(fmt.Sprintf("failed to create new user: %s", err.Error()))
}
log.Println(fmt.Sprintf("successfuly created user: \"%s\"", options.Username))
return nil
}
http.HandleFunc("/", managerIndexView)
http.HandleFunc("/edit/", managerEditView)
http.HandleFunc("/remove/", managerRemoveView)
http.HandleFunc("/publish", managerPublishView)
http.HandleFunc("/login", managerLoginView)
http.HandleFunc("/logout", managerLogoutView)
uri := fmt.Sprintf("%s:%d", address, port)
log.Println("listening to http://" + uri)

34
templates/login.html Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="icon" type="/image/png" href="/static/img/favicon.png"/>
<link rel="stylesheet" href="/static/css/form.css">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/login.css">
<link rel="stylesheet" href="/static/css/top.css">
<title>{{ .Title }}</title>
</head>
<body>
<div class="top">
<div class="title">
<h1>{{ .Title }}</h1>
</div>
</div>
<div class="login">
<div class="manager-form">
<form action="/login" method="post">
<label for="username">&ltUsername&gt</label>
<input type="text" class="input" name="username">
<label for="password">&ltPassword&gt</label>
<input type="text" class="input" name="password">
<input type="submit" style="display:none;" id="login">
<label type="submit" class="login fake-button" for="login">&ltLogin&gt</label>
</form>
</div>
{{ if .Message }}
<p class="message">{{ .Message }}</p>
{{ end }}
</div>
</body>
</html>

View File

@ -2,7 +2,7 @@
<div class="manager-top">
<div class="logging-buttom">
<a href="/logout">
<h1>{{ .UserName }} &ltLogout&gt</h1>
<h1>{{ .User.Name }} &ltLogout&gt</h1>
</a>
</div>
<div class="site-button">
@ -18,7 +18,7 @@
<head>
<meta charset="utf-8">
<link rel="icon" type="/image/png" href="/static/img/favicon.png"/>
<link rel="stylesheet" href="/static/css/publish.css">
<link rel="stylesheet" href="/static/css/form.css">
<link rel="stylesheet" href="/static/css/manager.css">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/top.css">
@ -27,10 +27,10 @@
<body>
{{ template "top" . }}
<div class="manager-publish">
<div class="manager-form">
<form enctype="multipart/form-data" action="/publish" method="post">
<label for="title">&ltTitle&gt</label>
<input type="text" class="title" name="title" onkeypress="return event.keyCode != 13;">
<input type="text" class="input" name="title" onkeypress="return event.keyCode != 13;">
<input type="file" name="image" style="display:none;" id="browse" accept=".jpg, .jpeg, .png">
<label type="submit" class="browse fake-button" for="browse" class="fake-button">&ltBrowse&gt</label>
<input type="submit" style="display:none;" id="publish">

View File

@ -1,4 +1,4 @@
.manager-publish .title {
.manager-form .input {
outline: none;
border: none;
background: none;
@ -9,18 +9,22 @@
border-bottom: 0.125em solid #ebdbb2;
}
.manager-publish label {
.manager-form .login-space {
margin-bottom: 1em;
}
.manager-form label {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.manager-publish {
.manager-form {
display: flex;
margin-top: 1em;
margin-bottom: 1em;
}
.manager-publish label {
.manager-form label {
text-align: center;
width: 100%;
display: inline-block;
@ -31,25 +35,25 @@
font-weight: bold;
}
.manager-publish label:hover {
.manager-form label:hover {
color: #ebdbb2;
background-color: #282828;
}
.manager-publish input {
.manager-form input {
padding: 0;
margin: 0;
}
.manager-publish .description {
.manager-form .description {
height: 5em;
}
.manager-publish * {
.manager-form * {
width: 100%;
}
.manager-publish .description {
.manager-form .description {
width: 100%;
display: flex;
}

View File

@ -0,0 +1,13 @@
body {
width: 75%;
}
.login {
width: 100%;
}
.login .message {
font-weight: bold;
text-align: center;
text-transform: uppercase;
}

1
testaapas Normal file
View File

@ -0,0 +1 @@
7oAhklgrWmAyaqdfD0ny3Z0uM3jAWSOilTM06iDomjboM8KXznDck7wj5UGqwWHeeWVwmnOzdKDLkaLLFeobyG1FO5upVYceVtSf

1
testpas Normal file
View File

@ -0,0 +1 @@
CW2trQSolcPHr3I005RLwdoiJMMisl6ZMAd5fFTCD8RUwPk0bf1WOqsGqzS5sroTNVVw53EJCgkO2vf4RwP1Znh4lkB7NbP34WUI