Add web UI for password resets (#123)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #123
This commit is contained in:
Max Erenberg 2024-03-23 19:26:30 -04:00
parent 32cb22665a
commit 7716f7bd10
37 changed files with 2270 additions and 0 deletions

View File

@ -158,9 +158,12 @@ def reset_user_password(username: str):
@bp.route('/<username>', methods=['DELETE'])
@requires_admin_creds
@authz_restrict_to_syscom
@development_only
def delete_user(username: str):
# We use the admin creds for the integration tests for the web app, which
# uses the ceod/<host> key
txn = DeleteMemberTransaction(username)
return create_streaming_response(txn)

View File

@ -11,6 +11,7 @@ x-common: &common
- ./tests:/app/tests:ro
# for flake8
- ./setup.cfg:/app/setup.cfg:ro
- ./web:/app/web:z
security_opt:
- label:disable
working_dir: /app

3
web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/ceod-web
/app.sock
/test

78
web/README.md Normal file
View File

@ -0,0 +1,78 @@
# ceod-web
This directory contains an experimental self-service web portal for CSC
members to use. It is an alternative to the ceo TUI. Currently it is only
meant to be used by general members, but in the future it may be extended
to be used by syscom/office members as well.
Implemented APIs:
- [x] Password reset
- [ ] Change login shell
- [ ] Change forwarding addresses
- [ ] Show membership terms
## Development
Make sure the Docker containers for ceo are running. Build the "web"
executable on the host, then run it in the phosphoric-acid container:
```sh
# Don't use cgo because the glibc version in the container will likely be
# older than the one on the host
CGO_ENABLED=0 go build -o ceod-web
docker-compose exec phosphoric-acid bash
cd web
./ceod-web -c dev.json
```
The application will listen on a Unix socket. In production, it expects to
receive ADFS information from Apache, which is acting as a reverse proxy.
In development, we will use our own proxy instead:
```sh
# On the host
go run scripts/proxy.go -s app.sock -u ctdalek -f Calum
```
Now you should be able to visit http://localhost:9988 in your browser.
NOTE: If you are not accessing the website via "localhost" (e.g. you are using
some custom proxy setup), then you need to modify the value of "app_url" in
dev.json. The app_url value must be equal to the base URL which you enter in
your browser's address bar, otherwise the cookie domain will not match.
You can change the `-u` (username) and `-f` (first name) arguments to the proxy.go
program to simulate a different user. See the .drone/data.ldif file in the parent
directory to see all mock users.
### Templates and static assets
In development, the templated views and static assets will be loaded from the
internal/views and internal/static folders, respectively. Unfortunately I think
that the Echo framework caches those files internally so you will need to
restart the app process if you modify them.
### Emails
By default, no emails will be sent; they will only be printed to stdout. To
send real emails, add these fields to the dev.json (replace `your_username`):
```json
{
...
"mta": "mail.csclub.uwaterloo.ca:25",
"forced_email_recipient": "your_username@csclub.uwaterloo.ca"
}
```
This will send all of the emails to your email address (the `To` header will
still be preserved, however).
## Tests
```sh
# On the host
CGO_ENABLED=0 go test -c -o test ./tests
# In the container
./test -test.v
```
## Deployment
Apache configuration on caffeine:
```
Redirect permanent /ceo /ceo/
<Location /ceo/ >
Include snippets/adfs-require-auth.conf
SetHandler "proxy:unix:/run/ceod-web/app.sock|fcgi://ceod-web/"
</Location>
```

7
web/dev.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "http://localhost:9988",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}

34
web/go.mod Normal file
View File

@ -0,0 +1,34 @@
module git.csclub.uwaterloo.ca/public/pyceo/web
go 1.21.4
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.4.2
github.com/stretchr/testify v1.8.4
)
require (
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

103
web/go.sum Normal file
View File

@ -0,0 +1,103 @@
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
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/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

178
web/internal/api/api.go Normal file
View File

@ -0,0 +1,178 @@
package api
import (
"fmt"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"net/http/fcgi"
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type TemplateManager struct {
templates map[string]*template.Template
}
func newTemplateManager(isDev bool) *TemplateManager {
var viewsFS fs.FS
if isDev {
viewsFS = os.DirFS("internal/views")
} else {
var err error
viewsFS, err = fs.Sub(internal.EmbeddedViews, "views")
if err != nil {
panic(err)
}
}
base := template.Must(template.ParseFS(viewsFS, "base.html"))
// Adapted from https://stackoverflow.com/a/24120195
parse := func(filenames ...string) *template.Template {
clone := template.Must(base.Clone())
return template.Must(clone.ParseFS(viewsFS, filenames...))
}
return &TemplateManager{templates: map[string]*template.Template{
"root": parse("root.html"),
"pwreset": parse("pwreset.html"),
"pwreset_confirmation": parse("pwreset_confirmation.html"),
"app_error": parse("app_error.html"),
"4xx": parse("4xx.html"),
"500": parse("500.html"),
}}
}
func (t *TemplateManager) Render(w io.Writer, name string, data any, c echo.Context) error {
tmpl, ok := t.templates[name]
if !ok {
c.Logger().Errorf("No such template '%s'", name)
return echo.NewHTTPError(http.StatusInternalServerError, "Internal server error")
}
return tmpl.Execute(w, data)
}
func addStaticAssets(isDev bool, e *echo.Echo) {
var staticFS fs.FS
if isDev {
staticFS = os.DirFS("internal")
} else {
staticFS = internal.EmbeddedAssets
}
e.Group("static").Use(middleware.StaticWithConfig(middleware.StaticConfig{
Root: "static",
Filesystem: http.FS(staticFS),
}))
}
func newListener(sockPath string) net.Listener {
if _, err := os.Stat(sockPath); err == nil {
err = os.Remove(sockPath)
if err != nil {
panic(fmt.Errorf("Could not remove %s: %w", sockPath, err))
}
}
l, err := net.Listen("unix", sockPath)
if err != nil {
panic(err)
}
// Only root should be allowed to write to the socket, otherwise
// users could forge the X-CSC-ADFS-* headers and reset other people's
// passwords
err = os.Chmod(sockPath, 0600)
if err != nil {
panic(err)
}
return l
}
func useFCGI(cfg *config.Config) bool {
return !cfg.IsDev
}
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
e := echo.New()
app := app.NewApp(cfg, mailSrv)
e.Logger.SetLevel(log.DEBUG)
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(helmet(cfg.IsDev))
addStaticAssets(cfg.IsDev, e)
e.Use(appContextMiddleware(useFCGI(cfg), app))
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "cookie:" + cfg.CookieName,
CookieName: "ceod-web-csrf",
CookiePath: cfg.GetAppPath(),
CookieDomain: cfg.GetAppHostname(),
CookieSecure: !cfg.IsDev,
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
}))
e.HTTPErrorHandler = httpErrorHandler
e.Renderer = newTemplateManager(cfg.IsDev)
addRoutes(e)
return e
}
func Start(cfg *config.Config) {
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
listener := newListener(cfg.SocketPath)
e.Logger.Info("Listening on " + cfg.SocketPath)
if useFCGI(cfg) {
e.Logger.Fatal(fcgi.Serve(listener, e))
} else {
e.Logger.Fatal(http.Serve(listener, e))
}
}
func addRoutes(e *echo.Echo) {
e.GET("/", getRoot)
e.GET("/pwreset", getPwreset)
e.POST("/pwreset", postPwreset)
}
func getRoot(c echo.Context) error {
ac := c.(*appContext)
_, err := ac.app.GetReqUser(ac)
if err != nil {
return err
}
return c.Render(http.StatusOK, "root", map[string]any{"firstName": ac.req.GivenName})
}
func getPwreset(c echo.Context) error {
ac := c.(*appContext)
emailAddress, err := ac.app.PwresetCheck(ac)
if err != nil {
return err
}
// Double-submit cookie
renderData := map[string]any{
"emailAddress": emailAddress,
"csrf": c.Get("csrf"),
}
return c.Render(http.StatusOK, "pwreset", renderData)
}
func postPwreset(c echo.Context) error {
if c.Get("csrf") != c.FormValue("_csrf") {
return newApiError(ERROR_INVALID_CSRF_TOKEN)
}
ac := c.(*appContext)
emailAddress, err := ac.app.Pwreset(ac)
if err != nil {
return err
}
renderData := map[string]any{"emailAddress": emailAddress}
return c.Render(http.StatusOK, "pwreset_confirmation", renderData)
}

View File

@ -0,0 +1,99 @@
package api
import (
"html/template"
"net/http"
"github.com/labstack/echo/v4"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
)
const (
ERROR_INVALID_CSRF_TOKEN = iota + 1
)
const (
membershipURL = "https://csclub.uwaterloo.ca/get-involved/"
syscomURL = "mailto:syscom@csclub.uwaterloo.ca"
)
type ApiError struct {
Code int
}
func newApiError(code int) ApiError {
return ApiError{Code: code}
}
func (a ApiError) Error() string {
return "API error"
}
func renderHTTPErrorPage(c echo.Context, code int, name string) {
data := map[string]any{"statusText": http.StatusText(code)}
if err := c.Render(code, name, data); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}
func renderAppErrorPage(c echo.Context, renderInfo *errorRenderInfo) {
if err := c.Render(http.StatusOK, "app_error", renderInfo); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}
type errorRenderInfo struct {
Title string
HtmlFragment template.HTML
}
func newErrorRenderInfo(title string, htmlFragment string) *errorRenderInfo {
return &errorRenderInfo{Title: title, HtmlFragment: template.HTML(htmlFragment)}
}
var appErrorInfos = map[int]*errorRenderInfo{
app.ERROR_NO_SUCH_USER: newErrorRenderInfo(
"You are not a CSC member :(",
"It seems like you're not a CSC member yet. "+
`Maybe you'd like to <a href="`+membershipURL+`">become one instead</a>?`),
app.ERROR_MEMBERSHIP_EXPIRED: newErrorRenderInfo(
"Your membership has expired",
`Please visit <a href="`+membershipURL+`">this page</a> for instructions `+
"on how to renew your membership."),
app.ERROR_OTHER: newErrorRenderInfo(
"Something went wrong",
"We seem to be having issues on our end. Please contact the "+
`<a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
app.ERROR_FAILED_TO_SEND_PWRESET_EMAIL: newErrorRenderInfo(
"Something went wrong",
"We weren't able to send the new password to your email address. Please "+
`contact the <a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
}
var apiErrorInfos = map[int]*errorRenderInfo{
ERROR_INVALID_CSRF_TOKEN: newErrorRenderInfo(
"Invalid CSRF token",
"Please go back and refresh the page to get a new token. If this "+
`error persists, please contact the <a href="`+syscomURL+`">`+
"Systems Committee</a>."),
}
func httpErrorHandler(err error, c echo.Context) {
if httpErr, ok := err.(*echo.HTTPError); ok {
if httpErr.Code/100 == 4 {
renderHTTPErrorPage(c, httpErr.Code, "4xx")
return
}
} else if appErr, ok := err.(app.AppError); ok {
renderAppErrorPage(c, appErrorInfos[appErr.Code])
return
} else if apiErr, ok := err.(ApiError); ok {
renderAppErrorPage(c, apiErrorInfos[apiErr.Code])
return
}
c.Logger().Error(err)
renderHTTPErrorPage(c, http.StatusInternalServerError, "500")
}

View File

@ -0,0 +1,111 @@
package api
import (
"errors"
"net/http"
"net/http/fcgi"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
)
func helmet(cfg *config.Config) echo.MiddlewareFunc {
cspSchemes := "https:"
if cfg.IsDev {
cspSchemes = "http: https:"
}
cspDirectives := []string{
"default-src 'self'",
"base-uri 'self'",
"font-src 'self' " + cspSchemes + " data:",
"form-action 'self'",
"frame-ancestors 'self'",
"img-src 'self' data:",
"object-src 'none'",
"script-src 'self'",
"script-src-attr 'none'",
"style-src 'self' " + cspSchemes + " 'unsafe-inline'",
}
if !cfg.IsDev {
cspDirectives = append(cspDirectives, "upgrade-insecure-requests")
}
csp := strings.Join(cspDirectives, ";")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
h := c.Response().Header()
h.Set(echo.HeaderContentSecurityPolicy, csp)
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Cross-Origin-Resource-Policy", "same-origin")
h.Set(echo.HeaderReferrerPolicy, "no-referrer")
if cfg.HstsMaxAge != 0 {
h.Set(
echo.HeaderStrictTransportSecurity,
"max-age="+strconv.FormatInt(int64(cfg.HstsMaxAge), 10),
)
}
return next(c)
}
}
}
func getReqInfoFromFCGI(r *http.Request) (*app.ReqInfo, error) {
env := fcgi.ProcessEnv(r)
u := new(app.ReqInfo)
u.Username = env["MELLON_samaccountname"]
u.GivenName = env["MELLON_givenname"]
if u.Username == "" {
return nil, errors.New("FCGI environment is missing username")
} else if u.GivenName == "" {
return nil, errors.New("FCGI environment is missing given name")
}
return u, nil
}
func getReqInfoFromHTTPHeaders(r *http.Request) (*app.ReqInfo, error) {
// header names must be in canonical form (see http.CanonicalHeaderKey)
usernames := r.Header["X-Csc-Adfs-Username"]
if len(usernames) == 0 {
return nil, errors.New("Username is missing from HTTP headers")
}
givenNames := r.Header["X-Csc-Adfs-Firstname"]
if len(givenNames) == 0 {
return nil, errors.New("Given name is missing from HTTP headers")
}
return &app.ReqInfo{Username: usernames[0], GivenName: givenNames[0]}, nil
}
type appContext struct {
echo.Context
req *app.ReqInfo
app *app.App
}
func (ac *appContext) Log() logging.Logger {
return ac.Context.Logger()
}
func (ac *appContext) Req() *app.ReqInfo {
return ac.req
}
func appContextMiddleware(useFCGI bool, app *app.App) echo.MiddlewareFunc {
getReqInfo := getReqInfoFromHTTPHeaders
if useFCGI {
getReqInfo = getReqInfoFromFCGI
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
reqInfo, err := getReqInfo(c.Request())
if err != nil {
return err
}
ac := &appContext{Context: c, req: reqInfo, app: app}
return next(ac)
}
}
}

55
web/internal/app/app.go Normal file
View File

@ -0,0 +1,55 @@
package app
import (
"net/http"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type App struct {
cfg *config.Config
ceod model.CeodService
mail service.MailService
}
func NewApp(cfg *config.Config, mailSrv service.MailService) *App {
return &App{
cfg: cfg,
ceod: service.NewCeodService(cfg),
mail: mailSrv,
}
}
func (app *App) GetUser(ctx AppContext, username string) (*model.User, error) {
// TODO: cache user lookups
cscUser, err := app.ceod.GetUser(ctx, username)
if err != nil {
if ceodErr, ok := err.(*model.CeodError); ok &&
ceodErr.HttpStatus == http.StatusNotFound {
return nil, newAppError(ERROR_NO_SUCH_USER)
}
return nil, newAppError(ERROR_OTHER)
}
if cscUser.ShadowExpire == 1 {
return nil, newAppError(ERROR_MEMBERSHIP_EXPIRED)
}
return cscUser, nil
}
func (app *App) GetReqUser(ctx AppContext) (*model.User, error) {
return app.GetUser(ctx, ctx.Req().Username)
}
type ReqInfo struct {
// This info comes from ADFS via SAML
Username string
GivenName string
}
type AppContext interface {
logging.ContextWithLogger
Req() *ReqInfo
}

View File

@ -0,0 +1,22 @@
package app
const (
ERROR_NO_SUCH_USER = iota + 1
ERROR_MEMBERSHIP_EXPIRED
ERROR_FAILED_TO_SEND_PWRESET_EMAIL
ERROR_OTHER
)
type AppError struct {
Code int
}
func newAppError(code int) AppError {
return AppError{Code: code}
}
func (a AppError) Error() string {
// This is just a placeholder; the real error message should be
// provided by the presentation layer
return "App error"
}

View File

@ -0,0 +1,50 @@
package app
import (
"strings"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
// PwresetCheck checks that the client is allowed to reset their password
// and returns the email address to which the new password would be sent.
func (a *App) PwresetCheck(ctx AppContext) (string, error) {
user, err := a.GetReqUser(ctx)
if err != nil {
return "", err
}
return a.getUWEmailAddress(user), nil
}
// Pwreset returns the email address to which the new password was sent
func (a *App) Pwreset(ctx AppContext) (string, error) {
user, err := a.GetReqUser(ctx)
if err != nil {
return "", err
}
newPassword, err := a.ceod.Pwreset(ctx, user.Uid)
if err != nil {
return "", err
}
emailAddress := a.getUWEmailAddress(user)
err = a.mail.Send(ctx, user.Cn, emailAddress, "pwreset_email.txt", map[string]any{
"firstName": user.GivenName,
"newPassword": newPassword,
})
if err != nil {
ctx.Log().Errorf("Failed to send new password email: %w", err)
return "", newAppError(ERROR_FAILED_TO_SEND_PWRESET_EMAIL)
}
return emailAddress, nil
}
func (a *App) getUWEmailAddress(user *model.User) string {
suffix := "@" + a.cfg.UWDomain
for _, address := range user.ForwardingAddresses {
if strings.HasSuffix(address, suffix) {
return address
}
}
// Fallback
return user.Uid + suffix
}

View File

@ -0,0 +1,134 @@
package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
)
const (
defaultKrb5Config = "/etc/krb5.conf"
defaultKrb5KtName = "/etc/krb5.keytab"
// The cookie name needs to be very unique because this app is
// being served from the root CSC domain
defaultCookieName = "ceod-web-csrf"
ceodHostname = "phosphoric-acid"
ceodPort = 9987
)
type Config struct {
SocketPath string `json:"socket_path"`
Krb5ConfigPath string `json:"-"`
Krb5KeytabPath string `json:"-"`
AppURL string `json:"app_url"`
CSCDomain string `json:"csc_domain"`
UWDomain string `json:"uw_domain"`
MTA string `json:"mta"`
CookieName string `json:"cookie_name"`
HstsMaxAge int `json:"hsts_max_age"`
IsDev bool `json:"dev"`
ForcedEmailRecipient string `json:"forced_email_recipient"`
appHostname string
appPath string
}
func envGet(name, defaultValue string) string {
if s := os.Getenv(name); s != "" {
return s
}
return defaultValue
}
func NewConfig(cfgPath string) *Config {
cfg := new(Config)
file, err := os.Open(cfgPath)
if err != nil {
panic(fmt.Errorf("Could not open %s: %w", cfgPath, err))
}
defer file.Close()
if err = json.NewDecoder(file).Decode(cfg); err != nil {
panic(fmt.Errorf("Could not decode JSON from %s: %w", cfgPath, err))
}
if cfg.SocketPath == "" {
panic(fmt.Errorf(`"socket_path" is missing from %s`, cfgPath))
}
cfg.Krb5ConfigPath = envGet("KRB5_CONFIG", defaultKrb5Config)
cfg.Krb5KeytabPath = envGet("KRB5_KTNAME", defaultKrb5KtName)
if cfg.CSCDomain == "" {
panic(fmt.Errorf(`"csc_domain" is missing from %s`, cfgPath))
}
if cfg.UWDomain == "" {
panic(fmt.Errorf(`"uw_domain" is missing from %s`, cfgPath))
}
if cfg.AppURL == "" {
panic(fmt.Errorf(`"app_url" is missing from %s`, cfgPath))
}
appURL := cfg.AppURL
if !strings.HasPrefix(appURL, "http://") && !strings.HasPrefix(appURL, "https://") {
appURL = "//" + appURL
}
parsedURL, err := url.Parse(appURL)
if err != nil {
panic(fmt.Errorf(`Could not parse URL "%s": %w`, appURL, err))
}
cfg.appHostname = parsedURL.Hostname()
if cfg.appHostname == "" {
panic(fmt.Errorf(`Could not parse URL "%s"`, appURL))
}
cfg.appPath = parsedURL.Path
if cfg.appPath == "" {
cfg.appPath = "/"
} else if cfg.appPath[len(cfg.appPath)-1] != '/' {
cfg.appPath += "/"
}
if cfg.CookieName == "" {
cfg.CookieName = defaultCookieName
}
if !cfg.IsDev && cfg.MTA == "" {
panic(fmt.Errorf(`"mta" is missing from %s`, cfgPath))
}
if !cfg.IsDev && cfg.ForcedEmailRecipient != "" {
panic(fmt.Errorf(`"forced_email_recipient" may only be used in development`))
}
return cfg
}
func getHostnameOrDie() string {
hostname, err := os.Hostname()
if err != nil {
panic(fmt.Errorf("Failed to get hostname: %w", err))
}
return hostname
}
func (c *Config) GetCeodBaseURL() string {
scheme := "https"
if c.IsDev {
scheme = "http"
}
return fmt.Sprintf("%s://%s.%s:%d", scheme, ceodHostname, c.CSCDomain, ceodPort)
}
func (c *Config) GetKrb5Principal() string {
hostname := getHostnameOrDie()
return fmt.Sprintf("ceod/%s.%s", hostname, c.CSCDomain)
}
func (c *Config) GetKrb5Realm() string {
return strings.ToUpper(c.CSCDomain)
}
func (c *Config) GetCeodSPN() string {
return fmt.Sprintf("ceod/%s.%s", ceodHostname, c.CSCDomain)
}
func (c *Config) GetAppHostname() string {
return c.appHostname
}
func (c *Config) GetAppPath() string {
return c.appPath
}

9
web/internal/internal.go Normal file
View File

@ -0,0 +1,9 @@
package internal
import "embed"
//go:embed views/*
var EmbeddedViews embed.FS
//go:embed static/*
var EmbeddedAssets embed.FS

View File

@ -0,0 +1,213 @@
package service
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
krb5client "github.com/jcmturner/gokrb5/v8/client"
krb5config "github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/spnego"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type Ceod struct {
apiBaseURL string
spnegoClient *spnego.Client
}
func NewCeodService(cfg *config.Config) model.CeodService {
krb5cfg, err := krb5config.Load(cfg.Krb5ConfigPath)
if err != nil {
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5ConfigPath, err))
}
kt, err := keytab.Load(cfg.Krb5KeytabPath)
if err != nil {
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5KeytabPath, err))
}
cl := krb5client.NewWithKeytab(cfg.GetKrb5Principal(), cfg.GetKrb5Realm(), kt, krb5cfg)
err = cl.Login()
if err != nil {
panic(fmt.Errorf("Failed to login to KDC: %w", err))
}
return &Ceod{
apiBaseURL: cfg.GetCeodBaseURL(),
spnegoClient: spnego.NewClient(cl, nil, cfg.GetCeodSPN()),
}
}
// TODO: auto-generate these from the OpenAPI definition
type ErrorResponse struct {
Error string `json:"error"`
}
type PwresetResponse struct {
Password string `json:"password"`
}
func (c *Ceod) getResp(ctx model.CeodRequestContext, method, urlPath string, body any) (*http.Response, error) {
ctx.Log().Debugf("%s %s", method, urlPath)
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("Could not marshal body: %w", err)
}
bodyReader = bytes.NewBuffer(buf)
}
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, bodyReader)
if err != nil {
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/json")
}
resp, err := c.spnegoClient.Do(r)
if err != nil {
return nil, fmt.Errorf("SPNEGO request failed: %w", err)
}
return resp, nil
}
func getRespError(ctx model.CeodRequestContext, resp *http.Response) error {
if resp.StatusCode/100 == 2 {
return nil
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf(
"HTTP status=%d, and failed to read body: %w",
resp.StatusCode, err,
)
}
var errBody ErrorResponse
err = json.Unmarshal(rawBody, &errBody)
var message string
if err != nil {
message = string(rawBody)
} else {
message = errBody.Error
}
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
}
func (c *Ceod) request(ctx model.CeodRequestContext, method, urlPath string, respBody any) error {
resp, err := c.getResp(ctx, method, urlPath, nil)
if err != nil {
return err
}
defer resp.Body.Close()
err = getRespError(ctx, resp)
if err != nil {
return err
}
err = json.NewDecoder(resp.Body).Decode(respBody)
if err != nil {
return fmt.Errorf("Failed to decode JSON response: %w", err)
}
return nil
}
func parseTransactionMessage[T any](b []byte) (model.ITransactionMessage, error) {
var txn model.TransactionMessage
if err := json.Unmarshal(b, &txn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
var itxn model.ITransactionMessage
switch txn.Status {
case "in progress":
itxn = new(model.TransactionInProgress)
case "completed":
itxn = new(model.TransactionCompleted[T])
case "aborted":
itxn = new(model.TransactionAborted)
default:
return nil, fmt.Errorf("Unrecognized transaction status '%s'", txn.Status)
}
if err := json.Unmarshal(b, itxn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
return itxn, nil
}
func requestTransaction[T any](
c *Ceod, ctx model.CeodTransactionRequestContext,
method, urlPath string, body any,
) (<-chan model.ITransactionMessage, error) {
resp, err := c.getResp(ctx, method, urlPath, body)
if err != nil {
return nil, err
}
err = getRespError(ctx, resp)
if err != nil {
resp.Body.Close()
return nil, err
}
txnChan := make(chan model.ITransactionMessage)
go func() {
defer resp.Body.Close()
defer close(txnChan)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
msg, err := parseTransactionMessage[T](scanner.Bytes())
if err != nil {
ctx.Log().Error(err)
return
}
select {
case txnChan <- msg:
continue
case <-ctx.CancelChan():
ctx.Log().Warn("Cancel chan closed, returning")
return
}
}
if err := scanner.Err(); err != nil {
ctx.Log().Error(err)
}
}()
return txnChan, nil
}
func (c *Ceod) GetUser(ctx model.CeodRequestContext, username string) (*model.User, error) {
user := &model.User{}
err := c.request(ctx, "GET", "/api/members/"+username, user)
return user, err
}
func (c *Ceod) GetUWUser(ctx model.CeodRequestContext, username string) (*model.UWUser, error) {
uwUser := &model.UWUser{}
err := c.request(ctx, "GET", "/api/uwldap/"+username, uwUser)
return uwUser, err
}
func (c *Ceod) Pwreset(ctx model.CeodRequestContext, username string) (string, error) {
resp := &PwresetResponse{}
err := c.request(ctx, "POST", "/api/members/"+username+"/pwreset", resp)
if err != nil {
return "", err
}
return resp.Password, nil
}
func (c *Ceod) AddUser(
ctx model.CeodTransactionRequestContext,
req *model.AddUserRequest,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[model.UserWithPassword](c, ctx, "POST", "/api/members", req)
}
func (c *Ceod) DeleteUser(
ctx model.CeodTransactionRequestContext,
username string,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[string](c, ctx, "DELETE", "/api/members/"+username, nil)
}

View File

@ -0,0 +1,129 @@
package service
import (
"bytes"
"embed"
"fmt"
"net/smtp"
"strings"
"text/template"
"time"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
)
type MailService interface {
Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
) error
}
type MailBackend interface {
Send(addr string, from string, to []string, msg []byte) error
}
type mail struct {
cfg *config.Config
backend MailBackend
}
type prodMailBackend struct{}
type devMailBackend struct{}
//go:embed pwreset_email.txt
var emailTemplateFiles embed.FS
var emailTemplates = make(map[string]*template.Template)
func init() {
templateFiles := []string{"pwreset_email.txt"}
for _, filename := range templateFiles {
contentBytes, err := emailTemplateFiles.ReadFile(filename)
if err != nil {
panic(err)
}
contentStr := string(contentBytes)
// The lines of the body need to be CRLF terminated
// See https://pkg.go.dev/net/smtp#SendMail
if strings.Contains(contentStr, "\r") {
panic(fmt.Errorf("File %s should not have carriage returns", filename))
}
contentStr = strings.ReplaceAll(contentStr, "\n", "\r\n")
emailTemplates[filename] = template.Must(template.New(filename).Parse(contentStr))
}
}
func NewMailServiceWithBackend(cfg *config.Config, backend MailBackend) MailService {
return &mail{cfg, backend}
}
func NewMailService(cfg *config.Config) MailService {
var backend MailBackend
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
backend = devMailBackend{}
} else {
backend = prodMailBackend{}
}
return NewMailServiceWithBackend(cfg, backend)
}
func (m *mail) senderAddress() string {
return "ceod+web@" + m.cfg.CSCDomain
}
func (m *mail) render(
recipientName, recipientAddress, tmplName string,
data map[string]any,
) ([]byte, error) {
tmpl, ok := emailTemplates[tmplName]
if !ok {
return nil, fmt.Errorf("No such email template '%s'", tmplName)
}
data["emailSenderName"] = "CSC Electronic Office"
data["emailSender"] = m.senderAddress()
data["emailRecipientName"] = recipientName
data["emailRecipient"] = recipientAddress
data["emailReplyTo"] = "no-reply@" + m.cfg.CSCDomain
data["emailDate"] = time.Now().Format(time.RFC1123Z)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("Failed to render template %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func (m *mail) Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
) error {
msg, err := m.render(recipientName, recipientAddress, tmplName, data)
if err != nil {
ctx.Log().Error(err)
return err
}
ctx.Log().Debugf("Sending email to %s", recipientAddress)
realRecipientAddress := recipientAddress
if m.cfg.ForcedEmailRecipient != "" {
realRecipientAddress = m.cfg.ForcedEmailRecipient
}
return m.backend.Send(m.cfg.MTA, m.senderAddress(), []string{realRecipientAddress}, msg)
}
func (m devMailBackend) Send(addr string, from string, to []string, msg []byte) error {
fmt.Printf("Would have sent this email:\n%s", string(msg))
return nil
}
func (m prodMailBackend) Send(addr string, from string, to []string, msg []byte) error {
return smtp.SendMail(
addr,
nil, // auth
from,
to,
msg,
)
}

View File

@ -0,0 +1,23 @@
From: {{ .emailSenderName }} <{{ .emailSender }}>
To: {{ .emailRecipientName }} <{{ .emailRecipient }}>
Reply-To: {{ .emailReplyTo }}
Subject: Computer Science Club Password Reset
Date: {{ .emailDate }}
Hello {{ .firstName }},
Your new temporary CSC password is:
{{ .newPassword }}
Please SSH into any general-use machine and change this password (you
will be prompted to do so when you login). Here are instructions on
how to do so:
https://wiki.csclub.uwaterloo.ca/How_to_SSH
If you have any questions or concerns, please contact
syscom@csclub.uwaterloo.ca.
Regards,
ceo

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="574px" height="252px" viewBox="0 0 574 252" preserveAspectRatio="xMidYMid meet">
<g fill="#000000">
<path d="M112.3 251 c-53.3 -5.6 -96.7 -43.8 -109.3 -96 -3.2 -13.1 -3.9 -36.8 -1.6 -50.6 10.6 -62.2 65.8 -106.8 128.8 -104.1 24.3 1 45.8 7.9 65.7 21.1 7.4 5 18.5 14.3 18.9 16 0.1 0.6 -2.7 2 -6.4 3 -29.7 8.5 -53.3 33 -61.6 64.1 -1.7 6.3 -2.2 10.9 -2.2 21.5 -0.1 11.9 0.3 14.6 2.7 23 4.9 16.6 15.7 33.8 27.2 43.3 l4.6 3.9 4.5 -3.8 c19 -15.5 31.4 -44.2 31.4 -72.7 0 -14.8 5.1 -35 12.7 -50.2 21.4 -42.6 64.8 -69.4 112.3 -69.5 16.3 0 35.6 3.7 48.4 9.4 l5.5 2.4 7.2 -2.9 c8.7 -3.6 19.6 -6.4 30.8 -8 10.4 -1.5 31.7 -0.6 42.1 1.6 46.8 10.3 83.5 45.2 95.5 91 8 30.3 4.6 61.9 -9.8 89.9 -17.5 34.2 -48.5 57.6 -87 65.7 -11.8 2.5 -32.8 3 -44.3 1.1 -23.8 -4 -45.5 -14.3 -65.1 -31 -2.9 -2.4 -5.3 -4.7 -5.3 -5.1 0 -0.4 3.3 -1.8 7.3 -3 36.4 -11.7 60.2 -41.6 64.1 -80.6 1.8 -17.9 -7.2 -44.5 -20.4 -60.7 -4.9 -6 -13.4 -13.8 -15 -13.8 -1.5 0 -11 9.2 -15.8 15.3 -6.7 8.6 -11.5 17.7 -15.1 29 -2.5 7.9 -3.2 12.4 -4.1 25.4 -1.3 19.1 -2.2 24.8 -6.1 37.8 -19.5 63.6 -86.9 101 -151.7 84 -5.1 -1.4 -12.2 -3.6 -15.6 -5 l-6.3 -2.5 -8.9 3.4 c-18.6 7 -38.5 9.6 -58.1 7.6z m28.1 -38 c0.5 -0.5 -0.9 -2.9 -3.7 -6 -21.4 -23.8 -33 -62.5 -28.7 -95.6 3.2 -25 16.2 -53.7 31.7 -70.6 l2.5 -2.6 -6.8 -0.7 c-42 -4.5 -82.6 22.3 -94.7 62.5 -5.7 19 -5.6 33.8 0.3 52.4 10.5 32.4 38.5 56.2 72.8 61.6 6.7 1 25.2 0.4 26.6 -1z m103.6 1 c16.8 -2.7 32.7 -9.7 45.1 -19.8 20.2 -16.4 32.8 -43.2 32.9 -69.7 0.1 -28.2 11.1 -57.7 29.8 -80.2 l5.1 -6.1 -7.6 -0.8 c-35.5 -3.3 -69.8 15.1 -87 46.6 -6.9 12.8 -11.3 30.8 -11.3 46.8 0 25.2 -11.3 54.6 -29.3 76.2 -2.5 3 -4.4 5.7 -4.1 5.9 1.8 1.9 17.5 2.5 26.4 1.1z m215 0 c41.5 -6.6 72 -37.7 77.2 -78.7 2.1 -16.3 -1.7 -35.3 -10.2 -51 -17.2 -32 -51.3 -50.2 -87.3 -46.8 l-7.7 0.7 4.5 5 c13.1 14.5 23.4 35.2 28 55.8 2.2 10.1 3.1 32.7 1.6 43.2 -3.1 21.6 -12.9 44.7 -26.5 62.3 -4.2 5.4 -6.2 8.7 -5.4 9 4.3 1.5 17.9 1.7 25.8 0.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

349
web/internal/static/normalize.css vendored Normal file
View File

@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@ -0,0 +1,67 @@
*, ::after, ::before {
box-sizing: border-box;
}
body {
min-height: 100vh;
padding: 0.2rem 1rem;
display: flex;
flex-direction: column;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;
background-color: var(--background-primary);
color: var(--text-primary);
}
:root {
--background-primary: #fff;
--text-primary: #1b1b1b;
--text-link: #0069c2;
--text-visited: #551a8b;
--accent-primary: #0085f2;
}
@media (prefers-color-scheme: dark) {
:root {
--background-primary: #1b1b1b;
--text-primary: #fff;
--text-link: #8cb4ff;
--text-visited: #ffadff;
--accent-primary: #5e9eff;
}
}
a {
color: var(--text-link);
}
a:active, a:active:visited {
background-color: var(--text-link);
color: #fff;
}
a:visited {
color: var(--text-visited);
}
:focus-visible {
outline-color: var(--accent-primary);
outline-offset: 1px;
}
input[type="submit"]:hover {
cursor: pointer;
}
main {
flex: 1 0;
}
footer {
font-size: 0.8rem;
}
.mt-10 { margin-top: 2.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }

View File

@ -0,0 +1,7 @@
{{ define "title" }}{{ .statusText }}{{ end }}
{{ define "main" }}
<p>
Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
if you are in need of assistance.
</p>
{{ end }}

View File

@ -0,0 +1,7 @@
{{ define "title" }}Internal server error{{ end }}
{{ define "main" }}
<p>
Oops! Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
for assistance.
</p>
{{ end }}

View File

@ -0,0 +1,4 @@
{{ define "title" }}{{ .Title }}{{ end }}
{{ define "main" }}
<p>{{ .HtmlFragment }}</p>
{{ end }}

View File

@ -0,0 +1,20 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="static/csc-logo.svg">
<link rel="stylesheet" href="static/normalize.css">
<link rel="stylesheet" href="static/styles.css">
<title>{{ block "title" . }}CSC Electronic Office{{ end }}</title>
</head>
<body>
<header>
<h1>{{ template "title" . }}</h1>
<hr>
</header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>
Copyright &copy; 2024 Computer Science Club of the University of Waterloo
</footer>
</body>
</html>

View File

@ -0,0 +1,26 @@
{{ define "title" }}Password Reset{{ end }}
{{ define "main" }}
<p>
<strong>NOTE</strong>: if you are able to SSH into a general-use machine
using a public key, then you do not need to use this form. Simply run
<code>passwd</code> instead.
</p>
<p>
Clicking the button below will generate a new temporary password for your
account which will be sent to the following email address:
</p>
<p>
<strong>{{ .emailAddress }}</strong>
</p>
<p>
If you do not have access to the email address above, please contact the
<a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a> for assistance.
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ .csrf }}" />
<input type="submit" class="py-2 px-6 text-lg" value="Reset my password" />
</form>
<p class="mt-10">
<a href="..">Return home</a>
</p>
{{ end }}

View File

@ -0,0 +1,14 @@
{{ define "title" }}Password Reset Confirmation{{ end }}
{{ define "main" }}
<p>
A new temporary password was sent to <b>{{ .emailAddress }}</b>.
</p>
<p>
PLEASE CHECK YOUR JUNK FOLDER. MS Outlook has an unfortunate tendency to place
our emails there. If you still have not received the confirmation email after
20 minutes, please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>.
</p>
<p class="mt-10">
<a href="..">Return home</a>
</p>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ define "title" }}CSC Electronic Office{{ end }}
{{ define "main" }}
<p>
Hello, {{ .firstName }}. What would you like to do?
</p>
<ul>
<li>
<a href="pwreset">Reset password</a>
</li>
</ul>
{{ end }}

18
web/main.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"flag"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
)
func main() {
configPath := flag.String("c", "", "config file")
flag.Parse()
if *configPath == "" {
panic("Config file must be specified")
}
cfg := config.NewConfig(*configPath)
api.Start(cfg)
}

View File

@ -0,0 +1,16 @@
package logging
type Logger interface {
Debug(i ...interface{})
Debugf(format string, args ...interface{})
Info(i ...interface{})
Infof(format string, args ...interface{})
Warn(i ...interface{})
Warnf(format string, args ...interface{})
Error(i ...interface{})
Errorf(format string, args ...interface{})
}
type ContextWithLogger interface {
Log() Logger
}

115
web/pkg/model/ceod.go Normal file
View File

@ -0,0 +1,115 @@
package model
import "git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
// TODO: auto-generate using OpenAPI definition
type CeodError struct {
HttpStatus int
Message string
}
func (e *CeodError) Error() string {
return e.Message
}
type CeodRequestContext logging.ContextWithLogger
type CeodTransactionRequestContext interface {
logging.ContextWithLogger
CancelChan() <-chan struct{}
}
type CeodService interface {
GetUser(ctx CeodRequestContext, username string) (*User, error)
GetUWUser(ctx CeodRequestContext, username string) (*UWUser, error)
Pwreset(ctx CeodRequestContext, username string) (string, error)
// On success, last message will be TransactionCompleted[UserWithPassword]
AddUser(ctx CeodTransactionRequestContext, req *AddUserRequest) (<-chan ITransactionMessage, error)
// On success, last message will be string "OK"
DeleteUser(ctx CeodTransactionRequestContext, username string) (<-chan ITransactionMessage, error)
}
type AddUserRequest struct {
// Full name
Cn string `json:"cn"`
// Last name
Sn string `json:"sn"`
// First name
GivenName string `json:"given_name"`
// Username
Uid string `json:"uid"`
Program string `json:"program"`
Terms int `json:"terms,omitempty"`
NonMemberTerms int `json:"non_member_terms,omitempty"`
ForwardingAddresses []string `json:"forwarding_addresses"`
}
type User struct {
// Full name
Cn string `json:"cn"`
// Last name
Sn string `json:"sn"`
// First name
GivenName string `json:"given_name"`
// Username
Uid string `json:"uid"`
UidNumber int `json:"uid_number"`
GidNumber int `json:"gid_number"`
HomeDirectory string `json:"home_directory"`
LoginShell string `json:"login_shell"`
Groups []string `json:"groups"`
Program string `json:"program"`
// Terms will be absent for club reps
Terms []string `json:"terms,omitempty"`
// NonMemberTerms will be absent for general members
NonMemberTerms []string `json:"non_member_terms,omitempty"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// ForwardingAddresses will be absent if the client does not have
// sufficient permissions. It will be empty if the client's
// ~/.forward is missing or empty.
ForwardingAddresses []string `json:"forwarding_addresses,omitempty"`
IsClub bool `json:"is_club"`
IsClubRep bool `json:"is_club_rep"`
// ShadowExpire will be 1 if the user's account is expired, and
// absent otherwise.
ShadowExpire int `json:"shadow_expire,omitempty"`
}
type UWUser struct {
// Username
Uid string `json:"uid"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// The following fields might be absent for alumni
Cn string `json:"cn,omitempty"`
GivenName string `json:"given_name,omitempty"`
Sn string `json:"sn,omitempty"`
// This field will always be absent for alumni
Program string `json:"program,omitempty"`
}
type ITransactionMessage interface{}
type TransactionMessage struct {
Status string `json:"status"`
}
type TransactionInProgress struct {
TransactionMessage // "in progress"
Operation string `json:"operation"`
}
type TransactionAborted struct {
TransactionMessage // "aborted"
Error string `json:"error"`
}
type TransactionCompleted[T any] struct {
TransactionMessage // "completed"
Result T `json:"result"`
}
type UserWithPassword struct {
User
Password string `json:"password"`
}

7
web/prod.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "https://csclub.uwaterloo.ca/ceo",
"socket_path": "/run/ceod-web/app.sock",
"csc_domain": "csclub.uwaterloo.ca",
"uw_domain": "uwaterloo.ca",
"mta": "mail.csclub.uwaterloo.ca:25"
}

75
web/scripts/proxy.go Normal file
View File

@ -0,0 +1,75 @@
package main
// Adapted from https://gist.github.com/yowu/f7dc34bd4736a65ff28d
import (
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
)
var (
username string
firstName string
port int
sockPath string
)
func dial(proto, addr string) (net.Conn, error) {
return net.Dial("unix", sockPath)
}
func newClient() *http.Client {
transport := &http.Transport{Dial: dial}
return &http.Client{Transport: transport}
}
type Proxy struct{}
func (p *Proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
client := newClient()
proxyReq, err := http.NewRequest(req.Method, "http://localhost"+req.RequestURI, req.Body)
if err != nil {
panic(err)
}
proxyReq.Header = req.Header
proxyReq.Header["X-CSC-ADFS-Username"] = []string{username}
proxyReq.Header["X-CSC-ADFS-FirstName"] = []string{firstName}
resp, err := client.Do(proxyReq)
if err != nil {
panic(err)
}
for k, v := range resp.Header {
wr.Header()[k] = v
}
wr.WriteHeader(resp.StatusCode)
_, err = io.Copy(wr, resp.Body)
if err != nil {
panic(err)
}
}
func main() {
sockPtr := flag.String("s", "", "socket path to proxy to")
portPtr := flag.Int("p", 9988, "port to listen on")
usernamePtr := flag.String("u", "", "username")
firstNamePtr := flag.String("f", "", "first name")
flag.Parse()
sockPath = *sockPtr
port = *portPtr
username = *usernamePtr
firstName = *firstNamePtr
if sockPath == "" || username == "" || firstName == "" {
fmt.Fprint(os.Stderr, "Usage: proxy [-p port] -s <socket path> -u <username> -f <first name>\n")
os.Exit(1)
}
addr := fmt.Sprintf("127.0.0.1:%d", port)
fmt.Fprintf(os.Stderr, "Listening on %s\n", addr)
err := http.ListenAndServe(addr, &Proxy{})
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,18 @@
[Unit]
Description=CSC Electronic Office daemon (web)
Documentation=https://git.csclub.uwaterloo.ca/public/pyceo
Wants=apache2.service
After=apache2.service
[Service]
Type=exec
WorkingDirectory=/srv/pyceo/web
ExecStart=/srv/pyceo/web/ceod-web -c prod.json
RuntimeDirectory=ceod-web
DynamicUser=yes
LoadCredential=krb5.keytab:/etc/krb5.keytab
Environment=KRB5_KTNAME=%d/krb5.keytab
ProtectSystem=strict
[Install]
WantedBy=multi-user.target

202
web/tests/common.go Normal file
View File

@ -0,0 +1,202 @@
package tests
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync"
"github.com/PuerkitoBio/goquery"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
// Make sure app_url is using "127.0.0.1", not "localhost", because that is
// what httptest.Server uses, and the cookie domain needs to match exactly
var Cfg = config.NewConfig("tests/test.json")
var Ceod = service.NewCeodService(Cfg)
var TestMail = TestMailBackend{}
var TestServer = httptest.NewServer(api.NewAPI(
Cfg, Ceod, service.NewMailServiceWithBackend(Cfg, &TestMail),
))
type MockEmail struct {
MTA string
From string
To []string
Msg []byte
}
type TestMailBackend struct {
SentEmails []MockEmail
mutex sync.Mutex
}
func (m *TestMailBackend) Send(addr string, from string, to []string, msg []byte) error {
msg = bytes.ReplaceAll(msg, []byte("\r\n"), []byte("\n"))
m.mutex.Lock()
m.SentEmails = append(m.SentEmails, MockEmail{addr, from, to, msg})
m.mutex.Unlock()
return nil
}
func (m *TestMailBackend) Reset() {
m.SentEmails = nil
}
var simpleUsernameCounter int
var simpleUsernameMutex sync.Mutex
func getNextSimpleUsername() string {
simpleUsernameMutex.Lock()
simpleUsernameCounter += 1
i := simpleUsernameCounter
simpleUsernameMutex.Unlock()
return fmt.Sprintf("ceodweb%d", i)
}
type TestLogger struct{}
var SharedTestLogger TestLogger
func (t *TestLogger) Debug(i ...interface{}) {}
func (t *TestLogger) Debugf(format string, args ...interface{}) {}
func (t *TestLogger) Info(i ...interface{}) {}
func (t *TestLogger) Infof(format string, args ...interface{}) {}
func (t *TestLogger) Warn(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Warnf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
func (t *TestLogger) Error(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Errorf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
type TestCeodTransactionRequestContext struct {
logger *TestLogger
cancelChan <-chan struct{}
}
func (t *TestCeodTransactionRequestContext) Log() logging.Logger {
return t.logger
}
func (t *TestCeodTransactionRequestContext) CancelChan() <-chan struct{} {
return t.cancelChan
}
func NewCeodTransactionRequestContext(cancelChan <-chan struct{}) model.CeodTransactionRequestContext {
return &TestCeodTransactionRequestContext{logger: &SharedTestLogger, cancelChan: cancelChan}
}
func handleTxn[T any](txnChan <-chan model.ITransactionMessage, err error) *T {
if err != nil {
panic(err)
}
for msg := range txnChan {
if _, ok := msg.(*model.TransactionInProgress); ok {
// ignore
} else if abortedMsg, ok := msg.(*model.TransactionAborted); ok {
panic(abortedMsg.Error)
} else if completedMsg, ok := msg.(*model.TransactionCompleted[T]); ok {
return &completedMsg.Result
} else {
panic(fmt.Errorf("Unrecognized message type: %+v", msg))
}
}
panic("Did not receive completed transaction message")
}
func AddSimpleUser() *model.UserWithPassword {
username := getNextSimpleUsername()
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
req := model.AddUserRequest{
Uid: username,
Cn: "John Doe",
GivenName: "John",
Sn: "Doe",
Program: "MAT/Mathematics Computer Science",
Terms: 1,
ForwardingAddresses: []string{username + "@" + Cfg.UWDomain},
}
return handleTxn[model.UserWithPassword](Ceod.AddUser(ctx, &req))
}
func DeleteUser(username string) {
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
result := *handleTxn[string](Ceod.DeleteUser(ctx, username))
if result != "OK" {
panic(fmt.Errorf("Result was not 'OK': '%s'", result))
}
}
type TestClient struct {
User *model.UserWithPassword
client http.Client
}
func NewTestClientFromUser(user *model.UserWithPassword) *TestClient {
return &TestClient{User: user, client: http.Client{}}
}
func NewTestClient() *TestClient {
jar, err := cookiejar.New(nil)
if err != nil {
panic(err)
}
return &TestClient{
User: AddSimpleUser(),
client: http.Client{Jar: jar},
}
}
// Destroy must be called if the client was created with NewTestClient()
func (c *TestClient) Destroy() {
DeleteUser(c.User.Uid)
}
func (c *TestClient) request(method, urlPath string, formData url.Values) (*goquery.Document, error) {
var bodyReader io.Reader
if formData != nil {
bodyReader = strings.NewReader(formData.Encode())
}
r, err := http.NewRequest(method, TestServer.URL+urlPath, bodyReader)
if err != nil {
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
r.Header.Set("X-CSC-ADFS-Username", c.User.Uid)
r.Header.Set("X-CSC-ADFS-FirstName", c.User.GivenName)
resp, err := c.client.Do(r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return goquery.NewDocumentFromReader(resp.Body)
}
func (c *TestClient) Get(urlPath string) (*goquery.Document, error) {
return c.request("GET", urlPath, nil)
}
func (c *TestClient) Post(urlPath string, formData url.Values) (*goquery.Document, error) {
return c.request("POST", urlPath, formData)
}

48
web/tests/pwreset_test.go Normal file
View File

@ -0,0 +1,48 @@
package tests
import (
"fmt"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestPwreset(t *testing.T) {
client := NewTestClient()
defer client.Destroy()
doc, err := client.Get("/pwreset")
require.Nil(t, err)
inputs := doc.Find("form input")
csrfInput := inputs.Filter(`[name="_csrf"]`)
require.Equal(t, 1, csrfInput.Length())
csrfToken, ok := csrfInput.Attr("value")
require.True(t, ok)
submitInput := inputs.Filter(`[type="submit"]`)
require.Equal(t, 1, submitInput.Length())
submitInputValue, ok := submitInput.Attr("value")
require.True(t, ok)
require.Equal(t, "Reset my password", submitInputValue)
defer TestMail.Reset()
doc, err = client.Post("/pwreset", url.Values{"_csrf": {csrfToken}})
require.Nil(t, err)
para := doc.Find("p").First()
uwEmail := client.User.ForwardingAddresses[0]
require.Contains(
t,
para.Text(),
fmt.Sprintf("A new temporary password was sent to %s.", uwEmail),
)
require.Len(t, TestMail.SentEmails, 1)
emailMsg := &TestMail.SentEmails[0]
require.Equal(t, "ceod+web@csclub.internal", emailMsg.From)
require.Equal(t, []string{uwEmail}, emailMsg.To)
matched, err := regexp.Match("(?m)^Subject: Computer Science Club Password Reset$", emailMsg.Msg)
require.Nil(t, err)
require.True(t, matched)
emailBodyStr := string(emailMsg.Msg)
require.Contains(t, emailBodyStr, "Your new temporary CSC password is:\n\n")
}

7
web/tests/test.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "http://127.0.0.1",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}