This commit is contained in:
Gregory Wells
2026-03-24 15:34:03 -04:00
commit 31ad22ad38
21 changed files with 1237 additions and 0 deletions

9
LICENSE Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

17
data/config.example.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

13
static/card.css Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

116
static/login_page.css Normal file
View 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
View 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
View 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;
}