Invitation mechanism with codes etc

This commit is contained in:
Alex 2020-02-14 21:58:34 +01:00
parent 768f2de916
commit 151a31a425
9 changed files with 329 additions and 33 deletions

View file

@ -600,9 +600,9 @@ func handleAdminCreate(w http.ResponseWriter, r *http.Request) {
} else { } else {
dn := data.IdType + "=" + data.IdValue + "," + super_dn dn := data.IdType + "=" + data.IdValue + "," + super_dn
req := ldap.NewAddRequest(dn, nil) req := ldap.NewAddRequest(dn, nil)
req.Attribute("objectClass", object_class) req.Attribute("objectclass", object_class)
if data.StructuralObjectClass != "" { if data.StructuralObjectClass != "" {
req.Attribute("structuralObjectClass", []string{data.StructuralObjectClass}) req.Attribute("structuralobjectclass", []string{data.StructuralObjectClass})
} }
if data.DisplayName != "" { if data.DisplayName != "" {
req.Attribute("displayname", []string{data.DisplayName}) req.Attribute("displayname", []string{data.DisplayName})

2
go.mod
View file

@ -3,6 +3,8 @@ module deuxfleurs.fr/Deuxfleurs/guichet
go 1.13 go 1.13
require ( require (
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b
github.com/emersion/go-smtp v0.12.1
github.com/go-ldap/ldap v3.0.3+incompatible github.com/go-ldap/ldap v3.0.3+incompatible
github.com/go-ldap/ldap/v3 v3.1.6 github.com/go-ldap/ldap/v3 v3.1.6
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3

5
go.sum
View file

@ -1,4 +1,9 @@
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-smtp v0.12.1 h1:1R8BDqrR2HhlGwgFYcOi+BVTvK1bMjAB65QcVpJ5sNA=
github.com/emersion/go-smtp v0.12.1/go.mod h1:SD9V/xa4ndMw77lR3Mf7htkp8RBNYuPh9UeuBs9tpUQ=
github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck= github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck=
github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk= github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk=

196
invite.go
View file

@ -1,15 +1,26 @@
package main package main
import ( import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt" "fmt"
"html/template" "html/template"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/gorilla/mux"
) )
var EMAIL_REGEXP = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus { func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus {
login := checkLogin(w, r) login := checkLogin(w, r)
if login == nil { if login == nil {
@ -24,6 +35,47 @@ func checkInviterLogin(w http.ResponseWriter, r *http.Request) *LoginStatus {
return login return login
} }
// New account creation directly from interface
func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) {
login := checkInviterLogin(w, r)
if login == nil {
return
}
handleNewAccount(w, r, login.conn)
}
// New account creation using code
func handleInvitationCode(w http.ResponseWriter, r *http.Request) {
code := mux.Vars(r)["code"]
code_id, code_pw := readCode(code)
l := ldapOpen(w)
if l == nil {
return
}
inviteDn := config.InvitationNameAttr + "=" + code_id + "," + config.InvitationBaseDN
err := l.Bind(inviteDn, code_pw)
if err != nil {
templateInviteInvalidCode := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_invalid_code.html"))
templateInviteInvalidCode.Execute(w, nil)
return
}
if handleNewAccount(w, r, l) {
del_req := ldap.NewDelRequest(inviteDn, nil)
err = l.Del(del_req)
if err != nil {
log.Printf("Could not delete invitation %s: %s", inviteDn, err)
}
}
}
// Common functions for new account
type NewAccountData struct { type NewAccountData struct {
Username string Username string
DisplayName string DisplayName string
@ -39,14 +91,9 @@ type NewAccountData struct {
Success bool Success bool
} }
func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) { func handleNewAccount(w http.ResponseWriter, r *http.Request, l *ldap.Conn) bool {
templateInviteNewAccount := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_new_account.html")) templateInviteNewAccount := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_new_account.html"))
login := checkInviterLogin(w, r)
if login == nil {
return
}
data := &NewAccountData{} data := &NewAccountData{}
if r.Method == "POST" { if r.Method == "POST" {
@ -60,10 +107,11 @@ func handleInviteNewAccount(w http.ResponseWriter, r *http.Request) {
password1 := strings.Join(r.Form["password"], "") password1 := strings.Join(r.Form["password"], "")
password2 := strings.Join(r.Form["password2"], "") password2 := strings.Join(r.Form["password2"], "")
tryCreateAccount(login.conn, data, password1, password2) tryCreateAccount(l, data, password1, password2)
} }
templateInviteNewAccount.Execute(w, data) templateInviteNewAccount.Execute(w, data)
return data.Success
} }
func tryCreateAccount(l *ldap.Conn, data *NewAccountData, pass1 string, pass2 string) { func tryCreateAccount(l *ldap.Conn, data *NewAccountData, pass1 string, pass2 string) {
@ -140,5 +188,137 @@ func tryCreateAccount(l *ldap.Conn, data *NewAccountData, pass1 string, pass2 st
data.Success = true data.Success = true
} }
func handleInviteSendCode(w http.ResponseWriter, r *http.Request) { // ---- Code generation ----
type SendCodeData struct {
ErrorMessage string
ErrorInvalidEmail bool
Success bool
CodeDisplay string
CodeSentTo string
WebBaseAddress string
}
type CodeMailFields struct {
From string
To string
Code string
InviteFrom string
WebBaseAddress string
}
func handleInviteSendCode(w http.ResponseWriter, r *http.Request) {
templateInviteSendCode := template.Must(template.ParseFiles("templates/layout.html", "templates/invite_send_code.html"))
login := checkInviterLogin(w, r)
if login == nil {
return
}
data := &SendCodeData{
WebBaseAddress: config.WebAddress,
}
if r.Method == "POST" {
r.ParseForm()
choice := strings.Join(r.Form["choice"], "")
if choice != "display" && choice != "send" {
http.Error(w, "Invalid entry", http.StatusBadRequest)
return
}
sendto := strings.Join(r.Form["sendto"], "")
trySendCode(login, choice, sendto, data)
}
templateInviteSendCode.Execute(w, data)
}
func trySendCode(login *LoginStatus, choice string, sendto string, data *SendCodeData) {
// Generate code
code, code_id, code_pw := genCode()
// Create invitation object in database
inviteDn := config.InvitationNameAttr + "=" + code_id + "," + config.InvitationBaseDN
req := ldap.NewAddRequest(inviteDn, nil)
req.Attribute("userpassword", []string{SSHAEncode([]byte(code_pw))})
req.Attribute("objectclass", []string{"top", "invitationCode"})
err := login.conn.Add(req)
if err != nil {
data.ErrorMessage = err.Error()
return
}
// If we want to display it, do so
if choice == "display" {
data.Success = true
data.CodeDisplay = code
return
}
// Otherwise, we are sending a mail
if !EMAIL_REGEXP.MatchString(sendto) {
data.ErrorInvalidEmail = true
return
}
templateMail := template.Must(template.ParseFiles("templates/invite_mail.txt"))
buf := bytes.NewBuffer([]byte{})
templateMail.Execute(buf, &CodeMailFields{
To: sendto,
From: config.MailFrom,
InviteFrom: login.WelcomeName(),
Code: code,
WebBaseAddress: config.WebAddress,
})
log.Printf("Sending mail to: %s", sendto)
var auth sasl.Client = nil
if config.SMTPUsername != "" {
auth = sasl.NewPlainClient("", config.SMTPUsername, config.SMTPPassword)
}
err = smtp.SendMail(config.SMTPServer, auth, config.MailFrom, []string{sendto}, buf)
if err != nil {
data.ErrorMessage = err.Error()
return
}
log.Printf("Mail sent.")
data.Success = true
data.CodeSentTo = sendto
}
func genCode() (code string, code_id string, code_pw string) {
random := make([]byte, 32)
n, err := rand.Read(random)
if err != nil || n != 32 {
log.Fatalf("Could not generate random bytes: %s", err)
}
a := binary.BigEndian.Uint32(random[0:4])
b := binary.BigEndian.Uint32(random[4:8])
c := binary.BigEndian.Uint32(random[8:12])
code = fmt.Sprintf("%03d-%03d-%03d", a%1000, b%1000, c%1000)
code_id, code_pw = readCode(code)
return
}
func readCode(code string) (code_id string, code_pw string) {
// Strip everything that is not a digit
code_digits := ""
for _, c := range code {
if c >= '0' && c <= '9' {
code_digits = code_digits + string(c)
}
}
id_hash := sha256.Sum256([]byte("Guichet ID " + code_digits))
pw_hash := sha256.Sum256([]byte("Guichet PW " + code_digits))
code_id = hex.EncodeToString(id_hash[:8])
code_pw = hex.EncodeToString(pw_hash[:16])
return
} }

66
main.go
View file

@ -23,16 +23,23 @@ type ConfigFile struct {
LdapServerAddr string `json:"ldap_server_addr"` LdapServerAddr string `json:"ldap_server_addr"`
LdapTLS bool `json:"ldap_tls"` LdapTLS bool `json:"ldap_tls"`
BaseDN string `json:"base_dn"` BaseDN string `json:"base_dn"`
UserBaseDN string `json:"user_base_dn"` UserBaseDN string `json:"user_base_dn"`
UserNameAttr string `json:"user_name_attr"` UserNameAttr string `json:"user_name_attr"`
GroupBaseDN string `json:"group_base_dn"` GroupBaseDN string `json:"group_base_dn"`
GroupNameAttr string `json:"group_name_attr"` GroupNameAttr string `json:"group_name_attr"`
InvitationBaseDN string `json:"invitation_base_dn"` InvitationBaseDN string `json:"invitation_base_dn"`
InvitationNameAttr string `json:"invitation_name_attr"` InvitationNameAttr string `json:"invitation_name_attr"`
InvitedMailFormat string `json:"invited_mail_format"` InvitedMailFormat string `json:"invited_mail_format"`
InvitedAutoGroups []string `json:"invited_auto_groups"` InvitedAutoGroups []string `json:"invited_auto_groups"`
WebAddress string `json:"web_address"`
MailFrom string `json:"mail_from"`
SMTPServer string `json:"smtp_server"`
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
AdminAccount string `json:"admin_account"` AdminAccount string `json:"admin_account"`
GroupCanInvite string `json:"group_can_invite"` GroupCanInvite string `json:"group_can_invite"`
GroupCanAdmin string `json:"group_can_admin"` GroupCanAdmin string `json:"group_can_admin"`
@ -51,11 +58,22 @@ func readConfig() ConfigFile {
HttpBindAddr: ":9991", HttpBindAddr: ":9991",
LdapServerAddr: "ldap://127.0.0.1:389", LdapServerAddr: "ldap://127.0.0.1:389",
LdapTLS: false, LdapTLS: false,
BaseDN: "dc=example,dc=com",
UserBaseDN: "ou=users,dc=example,dc=com", BaseDN: "dc=example,dc=com",
UserNameAttr: "uid", UserBaseDN: "ou=users,dc=example,dc=com",
GroupBaseDN: "ou=groups,dc=example,dc=com", UserNameAttr: "uid",
GroupNameAttr: "gid", GroupBaseDN: "ou=groups,dc=example,dc=com",
GroupNameAttr: "gid",
InvitationBaseDN: "ou=invitations,dc=example,dc=com",
InvitationNameAttr: "cn",
InvitedMailFormat: "{}@example.com",
InvitedAutoGroups: []string{},
WebAddress: "https://guichet.example.com",
MailFrom: "guichet@example.com",
SMTPServer: "smtp.example.com",
AdminAccount: "uid=admin,dc=example,dc=com", AdminAccount: "uid=admin,dc=example,dc=com",
GroupCanInvite: "", GroupCanInvite: "",
GroupCanAdmin: "gid=admin,ou=groups,dc=example,dc=com", GroupCanAdmin: "gid=admin,ou=groups,dc=example,dc=com",
@ -117,6 +135,7 @@ func main() {
r.HandleFunc("/invite/new_account", handleInviteNewAccount) r.HandleFunc("/invite/new_account", handleInviteNewAccount)
r.HandleFunc("/invite/send_code", handleInviteSendCode) r.HandleFunc("/invite/send_code", handleInviteSendCode)
r.HandleFunc("/invitation/{code}", handleInvitationCode)
r.HandleFunc("/admin/users", handleAdminUsers) r.HandleFunc("/admin/users", handleAdminUsers)
r.HandleFunc("/admin/groups", handleAdminGroups) r.HandleFunc("/admin/groups", handleAdminGroups)
@ -147,6 +166,17 @@ type LoginStatus struct {
CanInvite bool CanInvite bool
} }
func (login *LoginStatus) WelcomeName() string {
ret := login.UserEntry.GetAttributeValue("givenname")
if ret == "" {
ret = login.UserEntry.GetAttributeValue("displayname")
}
if ret == "" {
ret = login.Info.Username
}
return ret
}
func logRequest(handler http.Handler) http.Handler { func logRequest(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL) log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
@ -266,9 +296,8 @@ func ldapOpen(w http.ResponseWriter) *ldap.Conn {
// Page handlers ---- // Page handlers ----
type HomePageData struct { type HomePageData struct {
Login *LoginStatus Login *LoginStatus
WelcomeName string BaseDN string
BaseDN string
} }
func handleHome(w http.ResponseWriter, r *http.Request) { func handleHome(w http.ResponseWriter, r *http.Request) {
@ -280,15 +309,8 @@ func handleHome(w http.ResponseWriter, r *http.Request) {
} }
data := &HomePageData{ data := &HomePageData{
Login: login, Login: login,
BaseDN: config.BaseDN, BaseDN: config.BaseDN,
WelcomeName: login.UserEntry.GetAttributeValue("givenname"),
}
if data.WelcomeName == "" {
data.WelcomeName = login.UserEntry.GetAttributeValue("displayname")
}
if data.WelcomeName == "" {
data.WelcomeName = login.Info.Username
} }
templateHome.Execute(w, data) templateHome.Execute(w, data)

View file

@ -2,7 +2,7 @@
{{define "body"}} {{define "body"}}
<div class="alert alert-info"> <div class="alert alert-info">
Bienvenue, <strong>{{ .WelcomeName }}</strong> ! Bienvenue, <strong>{{ .Login.WelcomeName }}</strong> !
</div> </div>
<div class="d-flex"> <div class="d-flex">
<a class="ml-auto btn btn-sm btn-dark" href="/logout">Se déconnecter</a> <a class="ml-auto btn btn-sm btn-dark" href="/logout">Se déconnecter</a>

View file

@ -0,0 +1,12 @@
{{define "title"}}Créer un compte |{{end}}
{{define "body"}}
<div class="d-flex">
<h4>Création d'un nouveau compte</h4>
<a class="ml-auto btn btn-info" href="/">Retour</a>
</div>
<div class="alert alert-danger mt-4">
Code d'invitation invalide.
</div>
{{end}}

13
templates/invite_mail.txt Normal file
View file

@ -0,0 +1,13 @@
From: {{.From}}
To: {{.To}}
Subject: Code d'invitation Deuxfleurs
Content-type: text/plain; charset=utf-8
Vous avez été invité à créer un compte sur Deuxfleurs par {{.InviteFrom}} :)
Pour créer votre compte, rendez-vous à l'addresse suivante:
{{.WebBaseAddress}}/invite/{{.Code}}
À bientôt sur Deuxfleurs !

View file

@ -0,0 +1,62 @@
{{define "title"}}Envoyer un code d'invitation |{{end}}
{{define "body"}}
<div class="d-flex">
<h4>Envoyer un code d'invitation</h4>
<a class="ml-auto btn btn-info" href="/">Retour</a>
</div>
{{if .ErrorMessage}}
<div class="alert alert-danger mt-4">Impossible de génerer ou d'envoyer le code.
<div style="font-size: 0.8em">{{ .ErrorMessage }}</div>
</div>
{{end}}
{{if .Success}}
<div class="alert alert-success mt-4">
{{if .CodeSentTo}}
Un code d'invitation a bien été envoyé à <code>{{ .CodeSentTo }}</code>.
{{end}}
{{if .CodeDisplay}}
Le code généré est le suivant:
<p style="text-align: center; font-size: 1.6em;" class="mt-4 mb-4">
{{ .CodeDisplay }}
</p>
<p style="text-align: center" class="mt-4 mb-4">
<a href="{{.WebBaseAddress}}/invitation/{{ .CodeDisplay }}">{{.WebBaseAddress}}/invitation/{{.CodeDisplay}}</a>
</p>
{{end}}
</div>
{{else}}
<form method="POST" class="mt-4">
Choisissez une option:
<div class="input-group mt-4">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="radio" name="choice" value="display" id="choice_display" checked="true">
</div>
</div>
<label class="form-control" for="choice_display">
Afficher le code et me laisser l'envoyer
</label>
</div>
<div class="input-group mt-4">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="radio" name="choice" value="send" id="choice_send" aria-label="Checkbox for following text input">
</div>
</div>
<label class="form-control" for="choice_send">
Envoyer le code à l'addresse suivante:
</label>
<input class="form-control" type="text" name="sendto" id="sendto" placeholder="Addresse mail..." onclick="document.getElementById('choice_send').checked = true;" />
</div>
<div class="form-group mt-4">
<button type="submit" class="btn btn-primary">Génerer le code</button>
</div>
</form>
{{end}}
{{end}}