Ruby on Rails 2023/11/27 - 會員系統
===
今日重點:
- User
- sign up
- password hashed
- salt
- sign in
- session / cookie
- current_user
- user_signed_in?
# 註冊
### 建立model
```ternimal
rails g model User email password
```
- migration
```ruby
# 20231127030550_create_users
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email
t.string :password
t.timestamps
end
add_index :users, :email, unique: true
#增加這行
end
end
```
執行`rails db-migrate`
### model驗證
`validates :email, uniqueness:true`
- 任何加在model的驗證都不是對資料庫,而是對model
- 在migration內做驗證,可以有兩層鎖,model驗證&database驗證
- add_index 加索引,載入速度會變慢,也會建索引,unique:true
- model 寫入驗證
```ruby
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
validates :password, presence: true, length: {minimum:3, maximum:20}
end
```
### routes
有些是有id 有些是沒id
哪些時候需要**沒有**id? 購物車,會員系統
### resource or resources?
如果你知道沒有id,resource 就用單數寫法
如果是購物車和user就不需要id()
單數就沒有show
```ruby!
#routes.rb
resource :users, except: [:destroy]
```
### 到navbra新增註冊連結
```ruby
<%= link_to "註冊", new_users_path %>
```
### 利用終端機加controller
```
rails g controller Users
```
- 因為resources是複數,所以此處的Users也是
### Create
- users.controller
```
def new
@user = User.new
end
```
- new.html.erb
- 增加表單小幫手
```ruby!
#new.html.erb
<h1 class="text-2xl">註冊帳號</h1>
<%= form_with model: @user, data: { turbo: false } do |form| %>
<div>
<%= form.label :email %>
<%= form.email_field :email, class: 'input-field' %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, class: 'input-field' %>
</div>
<div>
<%= form.label :password_confirmation %>
<%= form.password_field :password_confirmation, class: 'input-field' %>
</div>
<div>
<%= form.submit class: 'ink-btn' %>
</div>
<% end %>
```
css
```
.input-field {
@apply border-2 border-black
}
```
### model增加validates-confirmation
到model
```ruby
#users.rb
validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true
```
- 此時Rails會幫忙增加一個虛擬的表單
new.html.erb加`:password_confirmation`
```
#new.html.erb
<div>
<%= f.label :password_confirmation%>
<%= f.password_field :password_confirmation, class: 'input-field' %>
</div>
```
users_controller做一個user_params
```ruby!
# users_controller
def user_params
params.require(:user)
.permit(:email, :password, :password_confirmation)
end
```
- 因為有寫confirmation,所以會做password_confirmation一個虛擬欄位
把create寫完
```ruby!
def create
@user = User.new(user_params)
if @user.save
redirect_to root_path, notice: '註冊成功'
else
render :new
end
end
```
進去`rails c` 試試看以下指令
```
irb(main):002> Digest::MD5.hexdigest('123')
=> "202cb962ac59075b964b07152d234b70"
```
### 加密(其實是雜湊)
- 設定加密(其實是雜湊),為了不想讓人看到密碼長怎麼,不管是系統管理員or駭客
- 在存擋前面,本身這個物件的password,利用Digest::SHA.hexdigest把密碼變成奇怪的樣子,加密的東西放model
- 想要在密碼存入前就改變,不要將原始密碼存入資料庫,就可以用before_save
```ruby!
#users.rb
before_save :encrypt_password
#after_save :send_text
private
def encrypt_password
self.password = Digest::SHA1.hexdigest(self.password)
end
```
- Salting
在寫進去任意固定位置插入特定的字串,可以想像成灑鹽巴,可以延長被猜出來的機會
在user.rb,`encrypt_password`方法內寫入`self.password = Digest::SHA1.hexdigest(self.password)`
```ruby
#users.rb
def encrypt_password
salted_password = hashed_password = "*xx#{self.paddword}yy-"
self.password = Digest::SHA1.hexdigest(self.password)
end
```
- 內部專案使用salting沒關係,但是開源專案會有問題
- 其實不能用before_save,因為create,update都會有,所以改用before_create
- 使用SHA256,而非SHA1
```ruby!
#users.rb
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
validates :password, presence: true, length: {minimum:3, maximum:20}, confirmation: true
before_create :encrypt_password
# before_save :encrypt_password
#after_save :send_text
private
#設定加密,為了不想讓人看到密碼長怎麼,不管是系統管理員or駭客
#在存擋前面,本身這個物件的password,利用Digest::SHA.hexdigest把密碼變成奇怪的樣子
def encrypt_password
salted_password = hashed_password = "*xx#{self.paddword}yy-"
self.password = Digest::SHA256.hexdigest(salted_password)
end
end
```
### 二階段驗證
- 除了密碼之後,還會有簡訊or其他軟體驗證
## 登入
- session
- 登入之後發號碼牌給使用者
- `resource :sessions, only: [:create, :destroy]`
- 不需要ID,所以單數就好,需要某些動詞即可,所以使用only
- 應該把大部分東西當資源來看,寫法和CRUD一樣
- 使用Create
- cookie
```ruby
routes.rb
get '/users/sign_in', to: 'users#sign_in'
```
- 要去哪裡其實都可以,不是強制,只是有關使用者體驗
- 除了原本的路徑以為,Rails可以幫你擴充其他路徑
### routes擴充
```ruby!
resources :products do
member do
get :mm
get :download
end
#寫成member帶有id
#會出現/products/:id/mm
#需要ID寫在member裏面
collection do
get :cc
get :special_sell
#會出現/products/mm
#不需要id可以寫在collection
end
end
```
### 幫原本的user擴充路徑
根據上述改寫
```ruby
resource :users, except: [:destroy] do
collection do
get :sign_in #登入表單
end
end
```

### 加上登入的連結
```ruby!
<%= link_to "登入帳號", sign_in_users_path %>
```
### form_for vs form_tag
- form_for(@product)
- 能做到類似form_with的效果,一定要model
- 能夠猜是新的還是舊的,新的幫你造、舊的幫你更新
- form_tag(url: 'bmi_calc')
- 不需要model
- form_with(model: @a)
- Rails 5以上才使用
- 有model是form_for,沒有model是form_tag
- 能夠整合以上兩種
- @a這個字,和方法定義的有關,ex: @a = User.new
- form_with() -> /users/new,因為nil所以默默轉成form_tag
### 製作from表單
```ruby!
# sign_in.html.erb
<h1 class="text-2xl">登入帳號</h1>
<%= form_with model: @user, data: { turbo: false } do |form| %>
<div>
<%= form.label :email %>
<%= form.email_field :email, class: 'input-field' %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, class: 'input-field' %>
</div>
<div>
<%= form.submit '登入', class: 'ink-btn' %>
</div>
<% end %>
```
改寫scope,就不用生一個東西出來
```ruby!
# sign_in.html.erb
<h1 class="text-2xl">登入帳號</h1>
<%= form_with (scope: 'user', data: { turbo: false }) do |form| %>
# 這裡改寫
<div>
<%= form.label :email %>
<%= form.email_field :email, class: 'input-field' %>
</div>
<div>
<%= form.label :password %>
<%= form.password_field :password, class: 'input-field' %>
</div>
<div>
<%= form.submit '登入', class: 'ink-btn' %>
</div>
<% end %>
```
[form_with](https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with)
- 但我們是想要把這個表單送出到session的create,所以自己寫路徑
### 新增session的routes
```ruby!
#routes.rb
resource :sessions, only: [:create, :destroy]
```
### 改寫form,加上
```ruby!
<%= form_with(url: users_sessions_path,
scope: 'user',
data: {turbo: false}) do |form| %>
```
- 原本就是POST所以不用加
到sessions.controller寫
```ruby!
#sessions.controller
user = User.login(param[:user])
if (user)
else
redirec_to sign_in_users_path, alert: '登入失敗'
end
```
### Fat Model, Thin Controller
把邏輯往model塞,Controller精簡,越簡單越好,
因為model是最容易被重複使用,view也能重複使用,Controller很少重複使用
### 實體方法
```ruby!
class Cat
end
kitty = Cat.new
def kitty.eat
puts kitty.eat
puts "hi"
end
```
- 單體方法singleton method可以定義實體方法&類別方法
- eat是個實體方法(instance method),作用在`kitty`上
- `Cat.hello`,是類別方法(class method),作用在類別身上的方法
- 在Class內如果要定義類別方法,前面要加self
```ruby!
class Cat
def slef.hello
end
end
```
### 把方法放進去mode
```ruby!
#user.rb
def self.login(user_params)
email = user_params[:email]
password = user_params[:password]
salted_password = Digest::SHA256.hexdigest(hashed_password = "*xx#{password}yy-")
find_by(email: email, password: salted_password)
end
```
可以改寫成
```ruby!
def self.login(user_params)
email = user_params[:email]
password = Digest::SHA256.hexdigest("*xx#{user_params[:password]}yy-")
find_by(email: email, password: password)
end
```
改寫變數
```ruby
def self.login(data)
email = data[:email]
password = Digest::SHA256.hexdigest("*xx#{data[:password]}yy-")
find_by(email: email, password: password)
end
```
key和value一樣可以改寫
```ruby=
find_by(email: , password: )
```
### 登入成功 & 失敗
```ruby=
sessions_controller.rb
if (user)
redirect_to sign_in_users_path, notice: '登入成功'
else
redirect_to sign_in_users_path, alert: '登入失敗'
end
```
### 發號碼牌
- 和flash很像,可以給session[:key]
```ruby=
flash[:notice] = '1111'
session[:__user_ticket__] = ???
```
- 可以給個不會重複的東西
```ruby=
if (user)
#發號碼牌
session[:__user_ticket__] = user.id
redirect_to sign_in_users_path, notice: '登入成功'
else
redirect_to sign_in_users_path, alert: '登入失敗'
end
```
### session & cookie ?
- cookie在server(客人那),ex:客人帶facebook專屬cookie出去
- session在broswer(店家這)
- 發號碼牌時後台要有一樣的ticket(就像是領飲料的流程)
- session和cookie要對得起來,你有cookie,我有session,我們對在一起才能登入
- 清cookie等於把網站登出,cookie會定期清,cookie過期就對不起來
- session reset的話,cookie號碼牌也撕掉,要重登
- 一般瀏覽器沒事不會把你踢出去
- 忘記裝置 = 清掉session
- 每一個request都會有cookie
### 要拿以下這一大串東西

### 渲染頁面,讓使用者知道登入了
```ruby
user-id: <%= session[:__users_ticket__]%>
```
### 製作小幫手
- 因為上面那段太長了,使用/helper來寫
- helper是製作controller時長出來的
- 所有的小幫手可以拿,沒有限制在哪一個頁面都可以,rails啟動時都會長出來
### helper
```
#Helper
module SessionsHelper
def current_user
session[:__user_ticket__]
end
end
```
- 載入順序,越下面排序越前面
- 有id了用find找user
用id找使用者
```
#UsersHelper
module UsersHelper
def current_user
User.find_by(id: session[:__user_ticket__])
end
end
```
layout改顯示email
#/layouts/application.html.erb
```ruby
user-id: <%= current_user.email %>
```
- 選擇自己相信的單位
- 如果改`current_user.password`,就會出現加密後的亂碼
- ?,是否,回傳為boolean
- ruby搜尋empty? -> 是不是空的
### 判斷使用者是否登入
寫在helper
```ruby
#usershelper
def user_signed_in?
current_user.present?
end
```
### navbar改寫
```ruby
<% if user_signed_in? %>
<li><%= current_user.email %></li>
<li><%= link_to '登出', '#' %></li>
<% else %>
<li><%= link_to '註冊', new_users_path %></li>
<li><%= link_to '登入', sign_in_users_path %></li>
<% end %>
```
- 無痕模式為什麼壞掉?因為沒有判斷有沒有登入,把layout的`user-id: <%= current_user.email %>`拿掉
### 會員套件 bundle add devise
$ bundle add devise
$ rails g devise:install
$ rails g devise User
$ rails db:migrate
請參考:[devise](https://github.com/heartcombo/devise)
## 登出
### 增加登出連結
```ruby!
#navar.html.erb
<li><%= link_to '登出', sessions_path, data: {turbo_method:'delete'} %></li>
```
- <a>一般超連結只有get屬性
- 為什麼會有這個效果?
Rails會去攔截頁面上的超連結,如果點擊發現有對到他的設定,例如像method,JavaScript就能幫助你做想做的事
在其他框架沒有這個效果
- fetch...then
### 在controller增加destory方法?
#sessions_controller.rb
```ruby!
def destroy
session[@__user_ticket__] == nil
redirect_to root_path, notice: '已登出'
end
```
- 會遇到:device登出,購物車做在session裡,東西也會跟著清掉
解決辦法:購物車不要放session裡
參考:[rails session](https://www.writesoftwarewell.com/rails-sessions/)
- 如果沒有就拿123(邏輯短路造成的回串值)
```
h[:name] || 123
-> 123
h.fetch(:name, 123)
-> false
```
- fetch是看key有沒有存在
#### falsy
- ruby falsy -> nill & false(只有兩種)
- [js falsy](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) -> null, undefined, false, NaN, 0, -0, 0n, "", document.all
#### dig
[api](https://apidock.com/ruby/Hash/dig)
```ruby
irb(main):001> h = {foo: {bar: {baz: 2}}}
=> {:foo=>{:bar=>{:baz=>2}}}
irb(main):002> h.dig(:foo)
=> {:bar=>{:baz=>2}}
```
### 應該使用delete方法
```ruby
# sessions_controller.rb
def destroy
session.delete(:__user_ticket__)
redirect_to root_path, notice: '已登出'
end
```