Reviewed-on: #123
This commit is contained in:
parent
32cb22665a
commit
7716f7bd10
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
/ceod-web
|
||||
/app.sock
|
||||
/test
|
|
@ -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>
|
||||
```
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"app_url": "http://localhost:9988",
|
||||
"socket_path": "app.sock",
|
||||
"csc_domain": "csclub.internal",
|
||||
"uw_domain": "uwaterloo.internal",
|
||||
"dev": true
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package internal
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed views/*
|
||||
var EmbeddedViews embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var EmbeddedAssets embed.FS
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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 |
|
@ -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;
|
||||
}
|
|
@ -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; }
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -0,0 +1,4 @@
|
|||
{{ define "title" }}{{ .Title }}{{ end }}
|
||||
{{ define "main" }}
|
||||
<p>{{ .HtmlFragment }}</p>
|
||||
{{ end }}
|
|
@ -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 © 2024 Computer Science Club of the University of Waterloo
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue