v0.0.1
This commit is contained in:
9
LICENSE
Normal file
9
LICENSE
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright © 2026 <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# FreeIPA Account Manager
|
||||||
|
|
||||||
|
A simple, lightweight web application for managing user profile photos in a FreeIPA server.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
* **LDAP Authentication**: Secure login with existing FreeIPA credentials.
|
||||||
|
* **Profile Management**: View user details (Display Name, Email).
|
||||||
|
* **Photo Upload**: Users can upload and update their profile picture (`jpegPhoto` attribute).
|
||||||
|
* **Session Management**: Secure, cookie-based sessions with CSRF protection.
|
||||||
|
* **Customizable**: Configurable styling (logo, favicon) and LDAP settings.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
* **Go 1.20+** installed on your machine.
|
||||||
|
* Access to an **FreeIPA Server**.
|
||||||
|
* A Service Account (Bind DN) with permission to search users and modify the `jpegPhoto` attribute.
|
||||||
|
|
||||||
|
## Setup & Installation
|
||||||
|
|
||||||
|
1. **Clone the Repository**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/GregoryWells2007/account-manager.git
|
||||||
|
cd account-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure the Application**
|
||||||
|
Copy the example configuration file to the production path:
|
||||||
|
```bash
|
||||||
|
cp data/config.example.json data/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Edit config**
|
||||||
|
put in your config values for ldap, and whatevery styling guidelines you would want to use
|
||||||
|
|
||||||
|
4. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the Server**
|
||||||
|
```bash
|
||||||
|
go run src/*.go
|
||||||
|
```
|
||||||
|
The application will be available at `http://<host>:<port>`.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
* `src/`: Go source code (`main.go`, `ldap.go`, `session.go`, etc.).
|
||||||
|
* `src/pages/`: HTML templates for login and profile pages.
|
||||||
|
* `static/`: CSS files, images, and other static assets.
|
||||||
|
* `data/`: Configuration files and local assets (logos).
|
||||||
|
* `avatars/`: Stores cached user profile photos.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
BIN
data/astraltech_logo.png
Normal file
BIN
data/astraltech_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
data/astraltech_logo_large.png
Normal file
BIN
data/astraltech_logo_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
17
data/config.example.json
Normal file
17
data/config.example.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"ldap_config": {
|
||||||
|
"ldap_url": "ldap://ipa.example.com:389",
|
||||||
|
"security": "tls",
|
||||||
|
"ignore_invalid_cert": false,
|
||||||
|
"base_dn": "dc=example,dc=com",
|
||||||
|
"bind_dn": "",
|
||||||
|
"bind_password": ""
|
||||||
|
},
|
||||||
|
"style_config": {
|
||||||
|
"favicon_path": "./data/astraltech_logo.png",
|
||||||
|
"logo_path": "./data/astraltech_logo_large.png"
|
||||||
|
},
|
||||||
|
"server_config": {
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
}
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module astraltech.xyz/accountmanager
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require github.com/go-ldap/ldap/v3 v3.4.13
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
)
|
||||||
40
go.sum
Normal file
40
go.sum
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
|
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/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
41
src/config.go
Normal file
41
src/config.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LDAPConfig struct {
|
||||||
|
LDAPURL string `json:"ldap_url"`
|
||||||
|
BaseDN string `json:"base_dn"`
|
||||||
|
BindDN string `json:"bind_dn"`
|
||||||
|
BindPassword string `json:"bind_password"`
|
||||||
|
Security string `json:"security"`
|
||||||
|
IgnoreInvalidCert bool `json:"ignore_invalid_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StyleConfig struct {
|
||||||
|
FaviconPath string `json:"favicon_path"`
|
||||||
|
LogoPath string `json:"logo_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebserverConfig struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
LDAPConfig LDAPConfig `json:"ldap_config"`
|
||||||
|
StyleConfig StyleConfig `json:"style_config"`
|
||||||
|
WebserverConfig WebserverConfig `json:"server_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadServerConfig(path string) (*ServerConfig, error) {
|
||||||
|
file, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg ServerConfig
|
||||||
|
err = json.Unmarshal(file, &cfg)
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
52
src/email.go
Normal file
52
src/email.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/smtp"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailAccount struct {
|
||||||
|
auth smtp.Auth
|
||||||
|
email string
|
||||||
|
smtpHost string
|
||||||
|
smtpPort string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailAccountData struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func createEmailAccount(accountData EmailAccountData, smtpHost string, smtpPort int) EmailAccount {
|
||||||
|
account := EmailAccount{
|
||||||
|
email: accountData.email,
|
||||||
|
smtpHost: smtpHost,
|
||||||
|
smtpPort: strconv.Itoa(smtpPort),
|
||||||
|
}
|
||||||
|
account.auth = smtp.PlainAuth("", accountData.username, accountData.password, smtpHost)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmail(account EmailAccount, toEmail []string, subject string, message string) {
|
||||||
|
ToEmailList := ""
|
||||||
|
for i := 0; i < len(toEmail); i++ {
|
||||||
|
ToEmailList += toEmail[i]
|
||||||
|
if i+1 < len(toEmail) {
|
||||||
|
ToEmailList += ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messageData := []byte(
|
||||||
|
"From: " + account.email + "\r\n" +
|
||||||
|
"To: " + ToEmailList + "\r\n" +
|
||||||
|
"Subject: " + subject + "\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
err := smtp.SendMail(account.smtpHost+":"+account.smtpPort, account.auth, account.email, toEmail, messageData)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/ldap.go
Normal file
119
src/ldap.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LDAPServer struct {
|
||||||
|
URL string
|
||||||
|
StartTLS bool
|
||||||
|
IgnoreInsecureCert bool
|
||||||
|
Connection *ldap.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
type LDAPSearch struct {
|
||||||
|
Succeeded bool
|
||||||
|
LDAPSearch *ldap.SearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectToLDAPServer(URL string, starttls bool, ignore_cert bool) (*LDAPServer, error) {
|
||||||
|
l, err := ldap.DialURL(URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if starttls {
|
||||||
|
if err := l.StartTLS(&tls.Config{InsecureSkipVerify: ignore_cert}); err != nil {
|
||||||
|
log.Println("StartTLS failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LDAPServer{
|
||||||
|
Connection: l,
|
||||||
|
URL: URL,
|
||||||
|
StartTLS: starttls,
|
||||||
|
IgnoreInsecureCert: ignore_cert,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconnectToLDAPServer(server *LDAPServer) {
|
||||||
|
if server == nil {
|
||||||
|
log.Println("Cannot reconnect: server is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := ldap.DialURL(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.StartTLS {
|
||||||
|
if err := l.StartTLS(&tls.Config{InsecureSkipVerify: server.IgnoreInsecureCert}); err != nil {
|
||||||
|
log.Println("StartTLS failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Connection = l
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectAsLDAPUser(server *LDAPServer, bindDN, password string) error {
|
||||||
|
if server == nil {
|
||||||
|
return errors.New("LDAP server is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect if needed
|
||||||
|
if server.Connection == nil || server.Connection.IsClosing() {
|
||||||
|
reconnectToLDAPServer(server)
|
||||||
|
}
|
||||||
|
return server.Connection.Bind(bindDN, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchLDAPServer(server *LDAPServer, baseDN string, searchFilter string, attributes []string) LDAPSearch {
|
||||||
|
if server == nil {
|
||||||
|
return LDAPSearch{false, nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.Connection == nil {
|
||||||
|
reconnectToLDAPServer(server)
|
||||||
|
if server.Connection == nil {
|
||||||
|
return LDAPSearch{false, nil}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchRequest := ldap.NewSearchRequest(
|
||||||
|
baseDN,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
searchFilter, attributes,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
sr, err := server.Connection.Search(searchRequest)
|
||||||
|
if err != nil {
|
||||||
|
return LDAPSearch{false, nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
return LDAPSearch{true, sr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modifyLDAPAttribute(server *LDAPServer, userDN string, attribute string, data []string) error {
|
||||||
|
modify := ldap.NewModifyRequest(userDN, nil)
|
||||||
|
modify.Replace(attribute, data)
|
||||||
|
err := server.Connection.Modify(modify)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeLDAPServer(server *LDAPServer) {
|
||||||
|
if server != nil && server.Connection != nil {
|
||||||
|
server.Connection.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ldapEscapeFilter(input string) string { return ldap.EscapeFilter(input) }
|
||||||
348
src/main.go
Normal file
348
src/main.go
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ldapServer *LDAPServer
|
||||||
|
ldapServerMutex sync.Mutex
|
||||||
|
serverConfig *ServerConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserData struct {
|
||||||
|
isAuth bool
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
photoCreatedTimestamp = make(map[string]time.Time)
|
||||||
|
photoCreatedMutex sync.Mutex
|
||||||
|
blankPhotoData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func createUserPhoto(username string, photoData []byte) error {
|
||||||
|
os.Mkdir("./avatars", os.ModePerm)
|
||||||
|
|
||||||
|
path := fmt.Sprintf("./avatars/%s.jpeg", username)
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
dst, err := os.Create(cleaned)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Not saving file\n")
|
||||||
|
return fmt.Errorf("Could not save file")
|
||||||
|
}
|
||||||
|
photoCreatedMutex.Lock()
|
||||||
|
photoCreatedTimestamp[username] = time.Now()
|
||||||
|
photoCreatedMutex.Unlock()
|
||||||
|
defer dst.Close()
|
||||||
|
_, err = dst.Write(photoData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticateUser(username, password string) (UserData, error) {
|
||||||
|
ldapServerMutex.Lock()
|
||||||
|
defer ldapServerMutex.Unlock()
|
||||||
|
if ldapServer.Connection == nil {
|
||||||
|
return UserData{isAuth: false}, fmt.Errorf("LDAP server not connected")
|
||||||
|
}
|
||||||
|
userDN := fmt.Sprintf("uid=%s,cn=users,cn=accounts,%s", username, serverConfig.LDAPConfig.BaseDN)
|
||||||
|
connected := connectAsLDAPUser(ldapServer, userDN, password)
|
||||||
|
if connected != nil {
|
||||||
|
return UserData{isAuth: false}, connected
|
||||||
|
}
|
||||||
|
|
||||||
|
userSearch := searchLDAPServer(
|
||||||
|
ldapServer,
|
||||||
|
serverConfig.LDAPConfig.BaseDN,
|
||||||
|
fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldapEscapeFilter(username)),
|
||||||
|
[]string{"displayName", "mail", "jpegphoto"},
|
||||||
|
)
|
||||||
|
if !userSearch.Succeeded {
|
||||||
|
return UserData{isAuth: false}, fmt.Errorf("user metadata not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := userSearch.LDAPSearch.Entries[0]
|
||||||
|
user := UserData{
|
||||||
|
isAuth: true,
|
||||||
|
Username: username,
|
||||||
|
DisplayName: entry.GetAttributeValue("displayName"),
|
||||||
|
Email: entry.GetAttributeValue("mail"),
|
||||||
|
}
|
||||||
|
|
||||||
|
photoData := entry.GetRawAttributeValue("jpegphoto")
|
||||||
|
if len(photoData) > 0 {
|
||||||
|
createUserPhoto(user.Username, photoData)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginPageData struct {
|
||||||
|
IsHiddenClassList string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl := template.Must(template.ParseFiles("src/pages/login_page.html"))
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
tmpl.Execute(w, LoginPageData{IsHiddenClassList: "hidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Logic for processing the form
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
username := r.FormValue("username")
|
||||||
|
if strings.Contains(username, "/") {
|
||||||
|
tmpl.Execute(w, LoginPageData{IsHiddenClassList: ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
log.Printf("New Login request for %s\n", username)
|
||||||
|
userData, err := authenticateUser(username, password)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
tmpl.Execute(w, LoginPageData{IsHiddenClassList: ""})
|
||||||
|
} else {
|
||||||
|
if userData.isAuth == true {
|
||||||
|
cookie := createSession(&userData)
|
||||||
|
if cookie == nil {
|
||||||
|
http.Error(w, "Session error", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusFound)
|
||||||
|
} else {
|
||||||
|
tmpl.Execute(w, LoginPageData{IsHiddenClassList: ""})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileData struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
CSRFToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
exist, sessionData := validateSession(r)
|
||||||
|
if !exist {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
tmpl := template.Must(template.ParseFiles("src/pages/profile_page.html"))
|
||||||
|
tmpl.Execute(w, ProfileData{
|
||||||
|
Username: sessionData.data.Username,
|
||||||
|
Email: sessionData.data.Email,
|
||||||
|
DisplayName: sessionData.data.DisplayName,
|
||||||
|
CSRFToken: sessionData.CSRFToken,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func avatarHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
username := r.URL.Query().Get("user")
|
||||||
|
if strings.Contains(username, "/") {
|
||||||
|
w.Write(blankPhotoData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := fmt.Sprintf("./avatars/%s.jpeg", username)
|
||||||
|
cleaned := filepath.Clean(filePath)
|
||||||
|
value, err := os.ReadFile(cleaned)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
photoCreatedMutex.Lock()
|
||||||
|
if time.Since(photoCreatedTimestamp[username]) <= 5*time.Minute {
|
||||||
|
photoCreatedMutex.Unlock()
|
||||||
|
w.Write(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
photoCreatedMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapServerMutex.Lock()
|
||||||
|
defer ldapServerMutex.Unlock()
|
||||||
|
connected := connectAsLDAPUser(ldapServer, serverConfig.LDAPConfig.BindDN, serverConfig.LDAPConfig.BindPassword)
|
||||||
|
if connected != nil {
|
||||||
|
w.Write(blankPhotoData)
|
||||||
|
fmt.Println("Returned blank avatar because couldnt connect as user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userSearch := searchLDAPServer(
|
||||||
|
ldapServer,
|
||||||
|
serverConfig.LDAPConfig.BaseDN,
|
||||||
|
fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldapEscapeFilter(username)),
|
||||||
|
[]string{"jpegphoto"},
|
||||||
|
)
|
||||||
|
if !userSearch.Succeeded || len(userSearch.LDAPSearch.Entries) == 0 {
|
||||||
|
w.Write(blankPhotoData)
|
||||||
|
fmt.Println("Returned blank avatar because we couldnt find the user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := userSearch.LDAPSearch.Entries[0]
|
||||||
|
bytes := entry.GetRawAttributeValue("jpegphoto")
|
||||||
|
if len(bytes) == 0 {
|
||||||
|
fmt.Println("Returned blank avatar because we just don't have an avatar")
|
||||||
|
w.Write(blankPhotoData)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
w.Write(bytes)
|
||||||
|
createUserPhoto(username, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session_token")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := cookie.Value
|
||||||
|
|
||||||
|
exist, sessionData := validateSession(r)
|
||||||
|
if exist {
|
||||||
|
if r.FormValue("csrf_token") != sessionData.CSRFToken {
|
||||||
|
http.Error(w, "Unable to log user out", http.StatusForbidden)
|
||||||
|
log.Printf("%s attempted to logout with invalid csrf token", sessionData.data.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMutex.Lock()
|
||||||
|
delete(sessions, token)
|
||||||
|
sessionMutex.Unlock()
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadPhotoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
exist, sessionData := validateSession(r)
|
||||||
|
if !exist {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := r.ParseMultipartForm(10 << 20) // 10MB limit
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.FormValue("csrf_token") != sessionData.CSRFToken {
|
||||||
|
http.Error(w, "CSRF Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("photo")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "File not found", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if header.Size > (10 * 1024 * 1024) {
|
||||||
|
http.Error(w, "File is to large (limit is 10 MB)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Read file into memory
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userDN := fmt.Sprintf("uid=%s,cn=users,cn=accounts,%s", sessionData.data.Username, serverConfig.LDAPConfig.BaseDN)
|
||||||
|
ldapServerMutex.Lock()
|
||||||
|
defer ldapServerMutex.Unlock()
|
||||||
|
modifyLDAPAttribute(ldapServer, userDN, "jpegphoto", []string{string(data)})
|
||||||
|
createUserPhoto(sessionData.data.Username, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func faviconHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, serverConfig.StyleConfig.FaviconPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, serverConfig.StyleConfig.LogoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupSessions() {
|
||||||
|
sessionMutex.Lock()
|
||||||
|
defer sessionMutex.Unlock()
|
||||||
|
|
||||||
|
sessions_to_delete := []string{}
|
||||||
|
for session_token, session_data := range sessions {
|
||||||
|
timeUntilRemoval := time.Minute * 5
|
||||||
|
if session_data.loggedIn {
|
||||||
|
timeUntilRemoval = time.Hour
|
||||||
|
}
|
||||||
|
if time.Since(session_data.timeCreated) > timeUntilRemoval {
|
||||||
|
sessions_to_delete = append(sessions_to_delete, session_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, session_id := range sessions_to_delete {
|
||||||
|
delete(sessions, session_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error = nil
|
||||||
|
|
||||||
|
blankPhotoData, err = os.ReadFile("static/blank_profile.jpg")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not load blank profile image")
|
||||||
|
}
|
||||||
|
serverConfig, err = loadServerConfig("./data/config.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Could not load server config")
|
||||||
|
}
|
||||||
|
|
||||||
|
ldapServerMutex.Lock()
|
||||||
|
server, err := connectToLDAPServer(serverConfig.LDAPConfig.LDAPURL, serverConfig.LDAPConfig.Security == "tls", serverConfig.LDAPConfig.IgnoreInvalidCert)
|
||||||
|
ldapServer = server
|
||||||
|
ldapServerMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeLDAPServer(ldapServer)
|
||||||
|
|
||||||
|
createWorker(time.Minute*5, cleanupSessions)
|
||||||
|
http.HandleFunc("/favicon.ico", faviconHandler)
|
||||||
|
http.HandleFunc("/logo", logoHandler)
|
||||||
|
|
||||||
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
http.HandleFunc("/login", loginHandler)
|
||||||
|
http.HandleFunc("/profile", profileHandler)
|
||||||
|
http.HandleFunc("/logout", logoutHandler)
|
||||||
|
|
||||||
|
http.HandleFunc("/avatar", avatarHandler)
|
||||||
|
http.HandleFunc("/change-photo", uploadPhotoHandler)
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusFound) // 302 redirect
|
||||||
|
})
|
||||||
|
|
||||||
|
serverAddress := fmt.Sprintf(":%d", serverConfig.WebserverConfig.Port)
|
||||||
|
log.Fatal(http.ListenAndServe(serverAddress, nil))
|
||||||
|
}
|
||||||
42
src/pages/login_page.html
Normal file
42
src/pages/login_page.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<title>Astral Tech - Login</title>
|
||||||
|
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="static/style.css" />
|
||||||
|
<link rel="stylesheet" href="static/login_page.css" />
|
||||||
|
|
||||||
|
<img id="logo_image" alt="logo" src="/logo" />
|
||||||
|
<form id="login_card" method="POST">
|
||||||
|
<div id="welcome_text">
|
||||||
|
Welcome to Astral Tech, Please Sign in to your account below
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="incorrect_password_text" class="{{.IsHiddenClassList}}">
|
||||||
|
⚠️ Invalid username or password.
|
||||||
|
<button id="incorrect_password_close_button">X</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="login_text">Username</label><br />
|
||||||
|
<input type="text" name="username" placeholder="" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="login_text">Password</label><br />
|
||||||
|
<input type="password" name="password" placeholder="" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document
|
||||||
|
.getElementById("incorrect_password_close_button")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
document
|
||||||
|
.getElementById("incorrect_password_text")
|
||||||
|
.classList.add("hidden");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
131
src/pages/profile_page.html
Normal file
131
src/pages/profile_page.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<title>Astral Tech - Profile</title>
|
||||||
|
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="static/style.css" />
|
||||||
|
<link rel="stylesheet" href="static/card.css" />
|
||||||
|
<link rel="stylesheet" href="static/profile_page.css" />
|
||||||
|
|
||||||
|
<img id="logo_image" alt="logo" src="/logo" />
|
||||||
|
<div id="main_content">
|
||||||
|
<div class="cards">
|
||||||
|
<div id="user-card" class="card">
|
||||||
|
<div id="picture_holder">
|
||||||
|
<img
|
||||||
|
src="/avatar?user={{.Username}}"
|
||||||
|
class="profile-pic"
|
||||||
|
alt="User Image"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file_input"
|
||||||
|
accept=".jpg,.jpeg"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button id="edit_picture_button">
|
||||||
|
<img
|
||||||
|
src="/static/crayon_icon.png"
|
||||||
|
id="edit_picture_image"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>{{.DisplayName}}</h1>
|
||||||
|
<hr
|
||||||
|
style="
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
margin: 20px 0;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style="text-align: left">
|
||||||
|
<span class="data-label">Username</span>
|
||||||
|
<div class="data-value">{{.Username}}</div>
|
||||||
|
<span class="data-label">Primary Email</span>
|
||||||
|
<div class="data-value">{{.Email}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="/logout"
|
||||||
|
method="POST"
|
||||||
|
style="
|
||||||
|
color: var(--primary-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border-style: none;
|
||||||
|
color: var(--primary-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 20px;
|
||||||
|
"
|
||||||
|
id="signout_button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="csrf_token_storage"
|
||||||
|
type="hidden"
|
||||||
|
name="csrf_token"
|
||||||
|
value="{{.CSRFToken}}"
|
||||||
|
/>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
const button = document.getElementById("edit_picture_button");
|
||||||
|
const fileInput = document.getElementById("file_input");
|
||||||
|
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
fileInput.click(); // opens file picker
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var currentPreviewURL = null;
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", async () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.type !== "image/jpeg") {
|
||||||
|
alert("Only JPEG images are allowed.");
|
||||||
|
fileInput.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("photo", file);
|
||||||
|
formData.append(
|
||||||
|
"csrf_token",
|
||||||
|
document.getElementById("csrf_token_storage").value,
|
||||||
|
);
|
||||||
|
const res = await fetch("/change-photo", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
// show the picture (so we don't need to re render the whole page)
|
||||||
|
const img = document.querySelector(".profile-pic");
|
||||||
|
|
||||||
|
if (currentPreviewURL != null) {
|
||||||
|
URL.revokeObjectURL(currentPreviewURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
currentPreviewURL = img.src;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
91
src/session.go
Normal file
91
src/session.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionData struct {
|
||||||
|
loggedIn bool
|
||||||
|
data *UserData
|
||||||
|
timeCreated time.Time
|
||||||
|
CSRFToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessions = make(map[string]SessionData)
|
||||||
|
sessionMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSessionToken(length int) (string, error) {
|
||||||
|
b := make([]byte, length)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSession(userData *UserData) *http.Cookie {
|
||||||
|
token, err := GenerateSessionToken(32) // Use crypto/rand for this
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
CSRFToken, err := GenerateSessionToken(32)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenEncoded := sha256.Sum256([]byte(token))
|
||||||
|
tokenEncodedString := string(tokenEncoded[:])
|
||||||
|
|
||||||
|
sessionMutex.Lock()
|
||||||
|
defer sessionMutex.Unlock()
|
||||||
|
loggedIn := false
|
||||||
|
if userData != nil {
|
||||||
|
loggedIn = true
|
||||||
|
}
|
||||||
|
sessions[tokenEncodedString] = SessionData{
|
||||||
|
data: userData,
|
||||||
|
timeCreated: time.Now(),
|
||||||
|
CSRFToken: CSRFToken,
|
||||||
|
loggedIn: loggedIn,
|
||||||
|
}
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true, // Essential: prevents JS access
|
||||||
|
Secure: true, // Set to TRUE in production (HTTPS)
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 3600, // 1 hour
|
||||||
|
}
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSession(r *http.Request) (bool, *SessionData) {
|
||||||
|
cookie, err := r.Cookie("session_token")
|
||||||
|
if err != nil {
|
||||||
|
return false, &SessionData{}
|
||||||
|
}
|
||||||
|
token := cookie.Value
|
||||||
|
|
||||||
|
tokenEncoded := sha256.Sum256([]byte(token))
|
||||||
|
tokenEncodedString := string(tokenEncoded[:])
|
||||||
|
|
||||||
|
sessionMutex.Lock()
|
||||||
|
sessionData, exists := sessions[tokenEncodedString]
|
||||||
|
sessionMutex.Unlock()
|
||||||
|
if !exists || !sessionData.loggedIn {
|
||||||
|
return false, &SessionData{}
|
||||||
|
}
|
||||||
|
return true, &sessionData
|
||||||
|
}
|
||||||
16
src/worker.go
Normal file
16
src/worker.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createWorker(interval time.Duration, task func()) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
task()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
BIN
static/blank_profile.jpg
Normal file
BIN
static/blank_profile.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
13
static/card.css
Normal file
13
static/card.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.card {
|
||||||
|
background: rgba(
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
255,
|
||||||
|
0.8
|
||||||
|
); /* Semi-transparent white for light theme */
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.03);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
BIN
static/crayon_icon.png
Normal file
BIN
static/crayon_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
116
static/login_page.css
Normal file
116
static/login_page.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#logo_image {
|
||||||
|
position: fixed;
|
||||||
|
width: 300px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card {
|
||||||
|
/* The Magic Three */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Stack vertically */
|
||||||
|
gap: 15px; /* Space between inputs */
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-50%);
|
||||||
|
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 350px;
|
||||||
|
|
||||||
|
/* Soft shadow for depth in light mode */
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.05),
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card input {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 324px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card button {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--primary-accent); /* Our Teal/Blue */
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#action-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signup_button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_button {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_card button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login_text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome_text {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#incorrect_password_text {
|
||||||
|
background-color: var(--error-red) !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--error-border-red);
|
||||||
|
border-width: 1px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#incorrect_password_close_button {
|
||||||
|
position: absolute !important;
|
||||||
|
background-color: rgba(0, 0, 0, 0) !important;
|
||||||
|
padding: 0px !important;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
color: black !important;
|
||||||
|
font-weight: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#incorrect_password_close_button:hover {
|
||||||
|
cursor: default;
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
105
static/profile_page.css
Normal file
105
static/profile_page.css
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#logo_image {
|
||||||
|
position: fixed;
|
||||||
|
width: 300px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#main_content {
|
||||||
|
position: fixed;
|
||||||
|
top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#profile_picture {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--primary-accent);
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 50px;
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-pic {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--primary-accent);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit_picture_button {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
/* move to circle edge at 45° */
|
||||||
|
transform: translate(-50%, -50%) translate(42px, 42px);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba();
|
||||||
|
|
||||||
|
background-color: var(--primary-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit_picture_button:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
#edit_picture_image {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#picture_holder {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#signout_button:hover {
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
29
static/style.css
Normal file
29
static/style.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-main: #f7fff7; /* Soft Mint White */
|
||||||
|
--bg-card: #ffffff; /* Pure White Card */
|
||||||
|
--bg-input: #f0f4f8; /* Light Gray-Blue Inset */
|
||||||
|
|
||||||
|
/* Text & Lines */
|
||||||
|
--text-main: #1a202c; /* Deep Charcoal (Better than pure black) */
|
||||||
|
--text-muted: #718096; /* Cool Gray for placeholders */
|
||||||
|
--border-subtle: #e2e8f0; /* Light Gray border */
|
||||||
|
|
||||||
|
/* Action Colors */
|
||||||
|
--primary-accent: #1a535c; /* Deep Teal (Trustworthy/Secure) */
|
||||||
|
--primary-hover: #14434a; /* Darker Teal for hover */
|
||||||
|
|
||||||
|
--error-red: #ef4444; /* Professional Red */
|
||||||
|
--error-border-red: #e22d2d;
|
||||||
|
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user